Files
IdeA/frontend/src/app/ViewWindow.tsx
Blomios 1a1429f10e 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.
2026-09-11 21:53:56 +02:00

314 lines
10 KiB
TypeScript

/**
* `ViewWindow` — the panel-only entry (ticket #23, reworked in #47).
*
* A detached OS window loads the same SPA with `?panel=<panel>` (no project id);
* {@link main} routes to this component instead of the full {@link App} when a
* `panel` param is present. It re-instantiates the DI (same adapters/gateways as
* the main window — views are gateway/read-model-driven, so no heavy state is
* transferred) and renders **only** the requested view for the project the main
* window currently has in focus.
*
* Focus, not embedding (#47): the window never carries — nor reopens — a project
* id. It reads the current focus from the {@link FocusedProjectGateway} at mount
* and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no
* project is focused it mounts **no** project-scoped panel and shows an "open a
* project" shell, so a window restored at restart never surfaces a stale
* project's data.
*
* Pure presentation over the ports: no `invoke()`, no cross-window messaging,
* and crucially no `openProject()` — opening a project is the main window's job.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CustomPluginLayoutCell } from "@/domain";
import type { FocusedProject } from "@/ports";
import { Panel } from "@/shared";
import { PANEL_TITLE, type PanelId } from "@/features/projects";
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
import {
onProjectThemeChanged,
projectThemeStyle,
validThemeColor,
} from "@/features/projects/projectTheme";
import {
PluginLayoutCellView,
PluginRuntimeProvider,
usePluginRuntime,
type PluginRuntimeContextValue,
} from "@/features/plugins";
import { useGateways } from "./di";
import { useI18n } from "@/i18n";
/** Panels that may be shown in a detached window (everything but "projects"). */
const DETACHABLE = new Set<string>(
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
);
/** Parsed `?panel=` params, or `null` when absent/invalid. */
export interface ViewWindowParams {
panel: ViewPanelId;
}
export interface PluginLayoutWindowParams {
pluginId: string;
layoutType: string;
state: unknown;
}
export type DetachedWindowParams =
| { kind: "panel"; panel: ViewPanelId }
| { kind: "pluginLayout"; plugin: PluginLayoutWindowParams };
function decodeBase64UrlPart(value: string): string | null {
try {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(
Math.ceil(value.length / 4) * 4,
"=",
);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
function decodeBase64UrlJson(value: string | null): unknown {
if (!value) return null;
const decoded = decodeBase64UrlPart(value);
if (!decoded) return null;
try {
return JSON.parse(decoded);
} catch {
return null;
}
}
/** Reads and validates the panel-only param from a query string (#47: no project). */
export function parseViewWindowParams(
search: string,
): DetachedWindowParams | null {
const params = new URLSearchParams(search);
if (params.get("pluginLayout") === "1") {
const pluginId = decodeBase64UrlPart(params.get("pluginId") ?? "");
const layoutType = decodeBase64UrlPart(params.get("layoutType") ?? "");
if (!pluginId || !layoutType) return null;
return {
kind: "pluginLayout",
plugin: {
pluginId,
layoutType,
state: decodeBase64UrlJson(params.get("state")),
},
};
}
const rawPanel = params.get("panel");
const panel = rawPanel === "context" ? "projectSettings" : rawPanel;
if (!panel || !DETACHABLE.has(panel)) return null;
return { kind: "panel", panel: panel as ViewPanelId };
}
export interface ViewWindowProps {
panel: ViewPanelId;
}
export function ViewWindow({ panel }: ViewWindowProps) {
const { focusedProject, project } = useGateways();
// The focused project this window follows. `undefined` = not yet resolved
// (initial read in flight); `null` = resolved, no project focused.
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
undefined,
);
const [themeColors, setThemeColors] = useState<Record<string, string | null>>({});
const themeRevisionRef = useRef<Record<string, number>>({});
// Read the current focus at mount, then track changes. Never opens a project:
// the window is a pure follower of the main window's focus (#47).
useEffect(() => {
let cancelled = false;
let unsubscribe: (() => void) | undefined;
let focusRevision = 0;
const initialRevision = focusRevision;
focusedProject
.getFocusedProject()
.then((project) => {
if (!cancelled && focusRevision === initialRevision) setFocus(project);
})
.catch(() => {
if (!cancelled && focusRevision === initialRevision) setFocus(null);
});
void focusedProject
.onFocusedProjectChanged((project) => {
focusRevision += 1;
if (!cancelled) setFocus(project);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [focusedProject]);
useEffect(() => {
if (!focus) return;
let cancelled = false;
const revision = themeRevisionRef.current[focus.id] ?? 0;
void project.readProjectSettings(focus.id).then((settings) => {
if (
!cancelled &&
(themeRevisionRef.current[focus.id] ?? 0) === revision
) {
setThemeColors((current) => ({
...current,
[focus.id]: validThemeColor(settings.themeColor),
}));
}
}).catch(() => {
// Keep the last known colour; settings content owns its own retry state.
});
return () => {
cancelled = true;
};
}, [focus?.id, project]);
useEffect(() => onProjectThemeChanged(({ projectId, themeColor }) => {
themeRevisionRef.current[projectId] =
(themeRevisionRef.current[projectId] ?? 0) + 1;
setThemeColors((current) => ({
...current,
[projectId]: validThemeColor(themeColor),
}));
}), []);
return (
<div
className="flex h-full flex-col bg-canvas text-content"
style={projectThemeStyle(focus ? themeColors[focus.id] : null)}
>
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">
{PANEL_TITLE[panel]}
</h1>
{focus && (
<span className="truncate text-xs text-muted">· {focus.name}</span>
)}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{focus ? (
// Only mount a project-scoped panel when a project is actually focused
// — the panels require a real `projectRoot`, never a placeholder.
<ViewPanelBody
panel={panel}
projectId={focus.id}
projectRoot={focus.root}
/>
) : (
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet dans la fenêtre principale pour afficher ce
panneau.
</p>
</Panel>
)}
</main>
</div>
);
}
export interface PluginLayoutWindowProps {
plugin: PluginLayoutWindowParams;
runtimeValue?: PluginRuntimeContextValue;
}
export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) {
return (
<PluginRuntimeProvider value={runtimeValue}>
<PluginLayoutWindowBody plugin={plugin} />
</PluginRuntimeProvider>
);
}
function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
const { tr } = useI18n();
const { focusedProject } = useGateways();
const { registry, loading } = usePluginRuntime();
const [focus, setFocus] = useState<FocusedProject | null | undefined>(undefined);
const [state, setState] = useState<unknown>(plugin.state);
const title =
registry
.get(plugin.pluginId)
?.contributes.layouts.find((layout) => layout.type === plugin.layoutType)?.label ??
plugin.layoutType;
const cell = useMemo<CustomPluginLayoutCell>(
() => ({
id: `plugin-window:${plugin.pluginId}:${plugin.layoutType}`,
pluginId: plugin.pluginId,
layoutType: plugin.layoutType,
state,
}),
[plugin.layoutType, plugin.pluginId, state],
);
useEffect(() => {
let cancelled = false;
let unsubscribe: (() => void) | undefined;
focusedProject
.getFocusedProject()
.then((project) => {
if (!cancelled) setFocus(project);
})
.catch(() => {
if (!cancelled) setFocus(null);
});
void focusedProject
.onFocusedProjectChanged((project) => {
if (!cancelled) setFocus(project);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [focusedProject]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">{title}</h1>
<span className="truncate text-xs text-muted">
· {registry.get(plugin.pluginId)?.displayName ?? plugin.pluginId}
</span>
{focus && <span className="truncate text-xs text-muted">· {focus.name}</span>}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{focus ? (
<PluginLayoutCellView
projectId={focus.id}
cell={cell}
onStateChange={setState}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
/>
) : (
<Panel>
<p className="text-sm text-muted">
{tr("Ouvrez un projet dans la fenêtre principale pour afficher ce layout.", "Open a project in the main window to display this layout.")}
</p>
</Panel>
)}
{loading && (
<p className="mt-3 text-xs text-muted">{tr("Chargement des plugins…", "Loading plugins…")}</p>
)}
</main>
</div>
);
}