Files
fusion/packages/dashboard/app/hooks/useDeepLink.ts
gsxdsm 1e49494bac feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
  English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
  carry ~5,930 keys each across common/app/errors/cli namespaces;
  CLI bundles regenerated (6 locales incl. ko)

Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
  plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
  moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
  literal {{placeholders}}); dashboard vitest.setup boots a minimal en
  i18next instance for the same reason

Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:06:53 -07:00

149 lines
4.6 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { TaskDetail } from "@fusion/core";
import { fetchTaskDetail, type ProjectInfo } from "../api";
import type { ToastType } from "./useToast";
interface UseDeepLinkOptions {
projectId?: string;
projects: ProjectInfo[];
projectsLoading: boolean;
currentProject: ProjectInfo | null;
setCurrentProject: (project: ProjectInfo) => void;
addToast: (message: string, type?: ToastType) => void;
openTaskDetail: (task: TaskDetail) => void;
closeTaskDetail: () => void;
}
export interface UseDeepLinkResult {
/**
* Call when the task detail modal closes.
* Cleans ?task=... from URL if the modal was opened via deep-link.
*/
handleDetailClose: () => void;
}
/**
* Handles task deep-link behavior (?project=...&task=...).
*/
export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
const { t } = useTranslation("app");
const {
projectId,
projects,
projectsLoading,
currentProject,
setCurrentProject,
addToast,
openTaskDetail,
closeTaskDetail,
} = options;
// Prevent duplicate fetches when project switching causes the effect to re-run.
const deepLinkFetchedRef = useRef(false);
// Guard against StrictMode double-effect path rewrites.
const pathRewroteRef = useRef(false);
// Track whether the currently open detail modal came from a deep-link.
const deepLinkTaskIdRef = useRef<string | null>(null);
// Avoid duplicate not-found toasts in StrictMode double-effect runs.
const projectNotFoundToastRef = useRef<string | null>(null);
// Ensure project switching from ?project= only happens once per project value.
const projectSwitchAppliedRef = useRef<string | null>(null);
useEffect(() => {
if (!pathRewroteRef.current) {
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
if (pathMatch) {
const taskIdFromPath = pathMatch[1];
if (/^[A-Z]+-\d+$/.test(taskIdFromPath)) {
const params = new URLSearchParams(window.location.search);
params.set("task", taskIdFromPath);
const query = params.toString();
const existingState = window.history.state ?? {};
window.history.replaceState(existingState, "", query ? `/?${query}` : "/");
pathRewroteRef.current = true;
}
}
}
const params = new URLSearchParams(window.location.search);
const projectParam = params.get("project");
const taskId = params.get("task");
if (projectsLoading) return;
let taskProjectId = projectId;
if (projectParam) {
const matchingProject = projects.find((project) => project.id === projectParam);
if (!matchingProject) {
if (projectNotFoundToastRef.current !== projectParam) {
addToast(t("deepLink.projectNotFound", "Project '{{id}}' not found", { id: projectParam }), "error");
projectNotFoundToastRef.current = projectParam;
}
return;
}
projectNotFoundToastRef.current = null;
taskProjectId = matchingProject.id;
if (
currentProject?.id !== matchingProject.id
&& projectSwitchAppliedRef.current !== matchingProject.id
) {
setCurrentProject(matchingProject);
projectSwitchAppliedRef.current = matchingProject.id;
}
} else {
projectNotFoundToastRef.current = null;
projectSwitchAppliedRef.current = null;
}
if (!taskId) return;
if (deepLinkFetchedRef.current) return;
deepLinkFetchedRef.current = true;
fetchTaskDetail(taskId, taskProjectId)
.then((detail) => {
openTaskDetail(detail);
deepLinkTaskIdRef.current = taskId;
})
.catch(() => {
addToast(t("deepLink.taskNotFound", "Task {{id}} not found", { id: taskId }), "error");
});
}, [
projectId,
projects,
projectsLoading,
currentProject,
setCurrentProject,
addToast,
openTaskDetail,
// deepLinkFetchedRef intentionally excluded - it's a mutable ref, not state
]);
const handleDetailClose = useCallback(() => {
if (deepLinkTaskIdRef.current) {
const params = new URLSearchParams(window.location.search);
params.delete("task");
const query = params.toString();
const existingState = window.history.state ?? {};
window.history.replaceState(
existingState,
"",
query ? `${window.location.pathname}?${query}` : window.location.pathname,
);
deepLinkTaskIdRef.current = null;
}
closeTaskDetail();
}, [closeTaskDetail]);
return { handleDetailClose };
}