feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
This commit is contained in:
@ -7,12 +7,13 @@ import type {
|
||||
} from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||
import { Button, Field, Input } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
type Preset = "hourly" | "daily" | "weekly" | "custom";
|
||||
|
||||
const DAYS: Array<[ScheduledTaskWeekday, string]> = [
|
||||
["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"],
|
||||
["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"],
|
||||
const DAYS: Array<[ScheduledTaskWeekday, string, string]> = [
|
||||
["monday", "Lun", "Mon"], ["tuesday", "Mar", "Tue"], ["wednesday", "Mer", "Wed"],
|
||||
["thursday", "Jeu", "Thu"], ["friday", "Ven", "Fri"], ["saturday", "Sam", "Sat"], ["sunday", "Dim", "Sun"],
|
||||
];
|
||||
|
||||
const control =
|
||||
@ -35,15 +36,19 @@ export function exactCommand(input: ScheduledTaskDefinitionInput): string {
|
||||
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
|
||||
}
|
||||
|
||||
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
|
||||
type Tr = (french: string, english: string) => string;
|
||||
const fr: Tr = (french) => french;
|
||||
|
||||
export function scheduleLabel(schedule: ScheduledTaskSchedule, tr: Tr = fr): string {
|
||||
if (schedule.kind === "interval") {
|
||||
return schedule.everyMinutes === 60
|
||||
? "Toutes les heures"
|
||||
: `Toutes les ${schedule.everyMinutes} minutes`;
|
||||
? tr("Toutes les heures", "Hourly")
|
||||
: tr(`Toutes les ${schedule.everyMinutes} minutes`, `Every ${schedule.everyMinutes} minutes`);
|
||||
}
|
||||
if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`;
|
||||
const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
|
||||
return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`;
|
||||
if (schedule.kind === "daily") return tr(`Chaque jour à ${schedule.time}`, `Every day at ${schedule.time}`);
|
||||
const frLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
|
||||
const enLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[2] ?? day);
|
||||
return tr(`Chaque semaine (${frLabels.join(", ")}) à ${schedule.time}`, `Every week (${enLabels.join(", ")}) at ${schedule.time}`);
|
||||
}
|
||||
|
||||
export function ScheduledTaskForm({
|
||||
@ -57,6 +62,7 @@ export function ScheduledTaskForm({
|
||||
onCancel: () => void;
|
||||
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
const [name, setName] = useState(task?.name ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
const [command, setCommand] = useState(task?.command.command ?? "");
|
||||
@ -116,22 +122,22 @@ export function ScheduledTaskForm({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
|
||||
Cette tâche pourra modifier ou supprimer des fichiers automatiquement.
|
||||
{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
|
||||
<dt className="text-muted">Dossier</dt><dd><code>{input.command.cwd ?? "."}</code> (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(input.schedule)}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
|
||||
<dt className="text-muted">Fuseau horaire</dt><dd>{input.timezone}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; aucun accès en écriture hors projet</dd>
|
||||
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
|
||||
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd><code>{input.command.cwd ?? "."}</code> ({tr("relatif au projet", "relative to project")})</dd>
|
||||
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(input.schedule, tr)}</dd>
|
||||
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
|
||||
<dt className="text-muted">{tr("Fuseau horaire", "Time zone")}</dt><dd>{input.timezone}</dd>
|
||||
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
|
||||
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; aucun accès en écriture hors projet", "Project — read/write; no write access outside the project")}</dd>
|
||||
</dl>
|
||||
<p className="text-xs text-muted">IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.</p>
|
||||
<p className="text-xs text-muted">{tr("IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.", "IdeA must be running and the project must be open to trigger this task.")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => setReview(false)}>Retour</Button>
|
||||
<Button onClick={() => setReview(false)}>{tr("Retour", "Back")}</Button>
|
||||
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
|
||||
{task?.approval === "pending" ? "Approuver et activer" : task ? "Enregistrer" : "Créer la tâche"}
|
||||
{task?.approval === "pending" ? tr("Approuver et activer", "Approve and enable") : task ? tr("Enregistrer", "Save") : tr("Créer la tâche", "Create task")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -141,45 +147,45 @@ export function ScheduledTaskForm({
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nom">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label="Dossier de travail" hint="Chemin relatif au projet; « . » utilise sa racine.">
|
||||
<Field label={tr("Nom", "Name")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label={tr("Dossier de travail", "Working directory")} hint={tr("Chemin relatif au projet ; « . » utilise sa racine.", "Path relative to the project; “.” uses its root.")}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={task?.approval === "pending" ? "Motif de la demande" : "Description (facultative)"}>
|
||||
<Field label={task?.approval === "pending" ? tr("Motif de la demande", "Request reason") : tr("Description (facultative)", "Description (optional)")}>
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
|
||||
</Field>
|
||||
<Field label="Commande" hint="Exécutable exact, sans interpolation shell.">
|
||||
<Field label={tr("Commande", "Command")} hint={tr("Exécutable exact, sans interpolation shell.", "Exact executable, without shell interpolation.")}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
|
||||
</Field>
|
||||
<Field label="Arguments" hint="Un argument littéral par ligne.">
|
||||
<Field label={tr("Arguments", "Arguments")} hint={tr("Un argument littéral par ligne.", "One literal argument per line.")}>
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Planification">
|
||||
<Field label={tr("Planification", "Schedule")}>
|
||||
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
|
||||
<option value="hourly">Toutes les heures</option><option value="daily">Chaque jour</option>
|
||||
<option value="weekly">Chaque semaine</option><option value="custom">Personnalisée</option>
|
||||
<option value="hourly">{tr("Toutes les heures", "Hourly")}</option><option value="daily">{tr("Chaque jour", "Daily")}</option>
|
||||
<option value="weekly">{tr("Chaque semaine", "Weekly")}</option><option value="custom">{tr("Personnalisée", "Custom")}</option>
|
||||
</select>}
|
||||
</Field>
|
||||
{preset === "custom" ? (
|
||||
<Field label="Intervalle (minutes)" error={Number(minutes) < 5 ? "Minimum : 5 minutes." : undefined}>
|
||||
<Field label={tr("Intervalle (minutes)", "Interval (minutes)")} error={Number(minutes) < 5 ? tr("Minimum : 5 minutes.", "Minimum: 5 minutes.") : undefined}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
|
||||
</Field>
|
||||
) : preset === "daily" || preset === "weekly" ? (
|
||||
<Field label="Heure locale">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
|
||||
<Field label={tr("Heure locale", "Local time")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
|
||||
) : <div />}
|
||||
</div>
|
||||
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">Jours</legend><div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(([key, label]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{label}</label>)}
|
||||
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">{tr("Jours", "Days")}</legend><div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(([key, frLabel, enLabel]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{tr(frLabel, enLabel)}</label>)}
|
||||
</div></fieldset>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Fuseau horaire" hint="Identifiant IANA, par exemple Europe/Paris.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
|
||||
<Field label="Limite de durée (minutes)" hint="Vide : aucune limite.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
|
||||
<Field label={tr("Fuseau horaire", "Time zone")} hint={tr("Identifiant IANA, par exemple Europe/Paris.", "IANA identifier, for example Europe/Paris.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
|
||||
<Field label={tr("Limite de durée (minutes)", "Time limit (minutes)")} hint={tr("Vide : aucune limite.", "Empty: no limit.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">Permissions et sandbox</div><p className="mt-1 text-muted">Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.</p></div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />Activée</label>
|
||||
<div className="flex justify-end gap-2"><Button onClick={onCancel}>Annuler</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? "Vérifier avant activation" : "Enregistrer"}</Button></div>
|
||||
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">{tr("Permissions et bac à sable", "Permissions and sandbox")}</div><p className="mt-1 text-muted">{tr("Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet ; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.", "Project — read/write. The command can edit or delete files in the project; writes outside the project are denied. Network is not isolated by this V1 profile.")}</p></div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />{tr("Activée", "Enabled")}</label>
|
||||
<div className="flex justify-end gap-2"><Button onClick={onCancel}>{tr("Annuler", "Cancel")}</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? tr("Vérifier avant activation", "Review before enabling") : tr("Enregistrer", "Save")}</Button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user