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.
314 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|