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.
192 lines
12 KiB
TypeScript
192 lines
12 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
|
||
import type {
|
||
ScheduledProjectTask,
|
||
ScheduledTaskSchedule,
|
||
ScheduledTaskWeekday,
|
||
} 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, string]> = [
|
||
["monday", "Lun", "Mon"], ["tuesday", "Mar", "Tue"], ["wednesday", "Mer", "Wed"],
|
||
["thursday", "Jeu", "Thu"], ["friday", "Ven", "Fri"], ["saturday", "Sam", "Sat"], ["sunday", "Dim", "Sun"],
|
||
];
|
||
|
||
const control =
|
||
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content outline-none focus:border-primary";
|
||
|
||
function initialPreset(schedule?: ScheduledTaskSchedule): Preset {
|
||
if (!schedule) return "daily";
|
||
if (schedule.kind === "interval" && schedule.everyMinutes === 60) return "hourly";
|
||
if (schedule.kind === "interval") return "custom";
|
||
return schedule.kind;
|
||
}
|
||
|
||
function shellWord(value: string): string {
|
||
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
|
||
? value
|
||
: `'${value.split("'").join(`'\\''`)}'`;
|
||
}
|
||
|
||
export function exactCommand(input: ScheduledTaskDefinitionInput): string {
|
||
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
|
||
}
|
||
|
||
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
|
||
? tr("Toutes les heures", "Hourly")
|
||
: tr(`Toutes les ${schedule.everyMinutes} minutes`, `Every ${schedule.everyMinutes} minutes`);
|
||
}
|
||
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({
|
||
task,
|
||
saving,
|
||
onCancel,
|
||
onSubmit,
|
||
}: {
|
||
task?: ScheduledProjectTask;
|
||
saving: boolean;
|
||
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 ?? "");
|
||
const [args, setArgs] = useState(task?.command.args.join("\n") ?? "");
|
||
const [cwd, setCwd] = useState(task?.command.cwd ?? ".");
|
||
const [preset, setPreset] = useState<Preset>(initialPreset(task?.schedule));
|
||
const [minutes, setMinutes] = useState(
|
||
task?.schedule.kind === "interval" ? String(task.schedule.everyMinutes) : "15",
|
||
);
|
||
const [time, setTime] = useState(
|
||
task?.schedule.kind === "daily" || task?.schedule.kind === "weekly"
|
||
? task.schedule.time
|
||
: "09:00",
|
||
);
|
||
const [days, setDays] = useState<ScheduledTaskWeekday[]>(
|
||
task?.schedule.kind === "weekly" ? task.schedule.days : ["monday"],
|
||
);
|
||
const [timezone, setTimezone] = useState(
|
||
task?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
|
||
);
|
||
const [timeoutMinutes, setTimeoutMinutes] = useState(
|
||
task?.timeoutMs ? String(Math.round(task.timeoutMs / 60_000)) : "",
|
||
);
|
||
const [enabled, setEnabled] = useState(task?.approval === "pending" ? true : task?.enabled ?? true);
|
||
const [review, setReview] = useState(false);
|
||
|
||
const input = useMemo<ScheduledTaskDefinitionInput>(() => {
|
||
const schedule: ScheduledTaskSchedule = preset === "hourly"
|
||
? { kind: "interval", everyMinutes: 60 }
|
||
: preset === "custom"
|
||
? { kind: "interval", everyMinutes: Number(minutes) }
|
||
: preset === "weekly"
|
||
? { kind: "weekly", days, time }
|
||
: { kind: "daily", time };
|
||
return {
|
||
name: name.trim(),
|
||
...(description.trim() ? { description: description.trim() } : {}),
|
||
command: {
|
||
command: command.trim(),
|
||
args: args.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||
...(cwd.trim() && cwd.trim() !== "." ? { cwd: cwd.trim() } : {}),
|
||
},
|
||
schedule,
|
||
timezone: timezone.trim(),
|
||
enabled,
|
||
...(timeoutMinutes ? { timeoutMs: Number(timeoutMinutes) * 60_000 } : {}),
|
||
};
|
||
}, [args, command, cwd, days, description, enabled, minutes, name, preset, time, timeoutMinutes, timezone]);
|
||
|
||
const valid = input.name.length > 0 && input.command.command.length > 0 &&
|
||
input.timezone.length > 0 &&
|
||
(input.schedule.kind !== "interval" || input.schedule.everyMinutes >= 5) &&
|
||
(input.schedule.kind !== "weekly" || input.schedule.days.length > 0) &&
|
||
(!timeoutMinutes || Number(timeoutMinutes) > 0);
|
||
|
||
if (review) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
|
||
{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">{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">{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)}>{tr("Retour", "Back")}</Button>
|
||
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
|
||
{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>
|
||
);
|
||
}
|
||
|
||
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={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" ? 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={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={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={tr("Planification", "Schedule")}>
|
||
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
|
||
<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={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={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">{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={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">{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>
|
||
);
|
||
}
|