diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 852464b201..f5b6e70970 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init" import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init" import { WebConnectionGuard } from "@/components/connection/web-connection-guard" import { WindowResizeGrips } from "@/components/layout/window-resize-grips" +import { WorkspaceLeaveGuard } from "@/components/workspace/workspace-leave-guard" export const viewport: Viewport = { width: "device-width", @@ -73,6 +74,7 @@ export default async function RootLayout({ + {children} diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index cef8021bb8..918566bcc1 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -35,6 +35,8 @@ import { WorkbenchRouteProvider, useWorkbenchRoute, } from "@/contexts/workbench-route-context" +import { WorkspaceWindowHistoryProvider } from "@/contexts/workspace-window-history" + import { WorkbenchRoutePage, WorkbenchRouteStrip, @@ -1267,8 +1269,9 @@ function WorkbenchRouteConversationSync() { function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { return ( - - + + + @@ -1314,8 +1317,9 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { - - + + + ) } diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 6e42cad6c1..ae951fc426 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -9,6 +9,7 @@ import { attachRef } from "@/lib/attach-ref" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" type DrawerContextProps = { hasSnapPoints: boolean @@ -177,6 +178,20 @@ function Drawer({ showSwipeHandle?: boolean }) { const hasSnapPoints = snapPoints != null && snapPoints.length > 0 + useBrowserBackWindow({ + open: props.open ?? false, + onClose: () => + onOpenChange?.(false, { + reason: "none", + event: new Event("close"), + cancel: () => {}, + allowPropagation: () => {}, + isCanceled: false, + isPropagationAllowed: true, + trigger: undefined, + preventUnmountOnClose: () => {}, + }), + }) const contextValue = React.useMemo( () => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }), [hasSnapPoints, modal, showSwipeHandle, swipeDirection] diff --git a/src/components/workspace/workspace-leave-guard.tsx b/src/components/workspace/workspace-leave-guard.tsx new file mode 100644 index 0000000000..a239ee1a3a --- /dev/null +++ b/src/components/workspace/workspace-leave-guard.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useEffect } from "react" +import { usePathname, useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { isDesktop } from "@/lib/platform" + +/** + * Confirms before a same-document back/forward traversal leaves /workspace, + * and before the page is closed or refreshed while the workspace is open. + * In-window navigation (drawers, in-memory routes) is handled by + * WorkspaceWindowHistoryProvider and never reaches this guard. + * Applies on every platform; only the beforeunload half skips the desktop + * client, where suppressing unload could block the app window from closing. + */ +export function WorkspaceLeaveGuard() { + const t = useTranslations("Folder.workspaceContext") + const pathname = usePathname() + const router = useRouter() + + useEffect(() => { + const onPopState = () => { + if ( + pathname !== "/workspace" || + window.location.pathname === "/workspace" + ) { + return + } + if (!window.confirm(t("confirmLeaveWorkspace"))) { + router.replace("/workspace", { scroll: false }) + } + } + window.addEventListener("popstate", onPopState) + return () => window.removeEventListener("popstate", onPopState) + }, [pathname, router, t]) + + useEffect(() => { + if (isDesktop() || pathname !== "/workspace") return + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = "" + } + window.addEventListener("beforeunload", onBeforeUnload) + return () => window.removeEventListener("beforeunload", onBeforeUnload) + }, [pathname]) + + return null +} diff --git a/src/contexts/workbench-route-context.tsx b/src/contexts/workbench-route-context.tsx index 6f2c3366f0..be8a17c7e8 100644 --- a/src/contexts/workbench-route-context.tsx +++ b/src/contexts/workbench-route-context.tsx @@ -8,6 +8,7 @@ import { useState, type ReactNode, } from "react" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" /** * The view occupying the main content region. `"conversations"` is the default @@ -77,6 +78,12 @@ export function WorkbenchRouteProvider({ children }: { children: ReactNode }) { const setRoute = useCallback((id: WorkbenchRouteId) => setRouteId(id), []) const openConversations = useCallback(() => setRouteId("conversations"), []) + useBrowserBackWindow({ + open: routeId !== "conversations", + onClose: openConversations, + key: "workbench-route", + }) + const value = useMemo( () => ({ routeId, diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index d312fdb8a5..e4b28a70b4 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -12,6 +12,7 @@ import { } from "react" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { buildFileTabId } from "@/lib/file-tab-id" import { @@ -111,7 +112,9 @@ interface WorkspaceActionsValue { switchFileTab: (tabId: string) => void closeFileTab: (tabId: string) => void closeOtherFileTabs: (tabId: string) => void - closeAllFileTabs: () => void + /** Returns false when the user vetoes the dirty-tabs confirm, so the + * browser-back integration can restore the history entry it consumed. */ + closeAllFileTabs: () => boolean reorderFileTabs: (tabs: FileWorkspaceTab[]) => void // Open a file tab. Accepts absolute paths, `~/` paths (expanded via the // backend home dir), and paths relative to a folder root. `folderId` is @@ -2243,13 +2246,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { [activateFilePane, t] ) - const closeAllFileTabs = useCallback(() => { - setFileTabs((prev) => { - if (prev.some(isDirtyFileTab)) { - const confirmed = window.confirm(t("confirmCloseAllDirtyTabs")) - if (!confirmed) return prev - } + const closeAllFileTabs = useCallback((): boolean => { + // Confirm outside the state updater: the browser-back handler needs the + // veto synchronously to decide whether to restore the history entry. + if (fileTabsRef.current.some(isDirtyFileTab)) { + const confirmed = window.confirm(t("confirmCloseAllDirtyTabs")) + if (!confirmed) return false + } + setFileTabs((prev) => { for (const tab of prev) { const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) @@ -2261,8 +2266,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { activateConversationPane() return [] }) + return true }, [activateConversationPane, t]) + useBrowserBackWindow({ + open: fileTabs.length > 0, + onClose: closeAllFileTabs, + key: "file-workspace", + }) + const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => { setFileTabs(tabs) }, []) diff --git a/src/contexts/workspace-window-history.tsx b/src/contexts/workspace-window-history.tsx new file mode 100644 index 0000000000..b0607f6df6 --- /dev/null +++ b/src/contexts/workspace-window-history.tsx @@ -0,0 +1,239 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + type ReactNode, +} from "react" + +type CloseSource = "manual" | "abandon" + +type Entry = { + key: string + close: () => boolean | void + /** Set when the browser back button (not the UI) dismissed this entry. */ + popped?: boolean +} + +type HistoryContextValue = { + open: (entry: Entry) => Entry + close: (entry: Entry, source: CloseSource) => void + attach: (entry: Entry) => Entry | null +} + +const HistoryContext = createContext(null) +const STATE_KEY = "codegWorkspaceWindow" + +function pushWindowState(key: string) { + if (typeof window === "undefined") return + // Spread the current state so entries pushed through Next.js' patched + // pushState keep `__NA` and the internal router tree — without them the + // app router reloads the page when it sees our entries on popstate. + window.history.pushState( + { ...(window.history.state ?? {}), [STATE_KEY]: key }, + "", + window.location.href + ) +} + +function stateKeyOf(state: unknown): string | null { + if (!state || typeof state !== "object") return null + const key = (state as Record)[STATE_KEY] + return typeof key === "string" ? key : null +} + +/** + * A LIFO stack of "windows" (drawers, in-memory routes, the file workspace) + * mirrored into the browser history so the back button (mobile gesture, + * hardware key, or desktop browser chrome) dismisses the topmost window + * instead of leaving the workspace. Applies on every platform — web and + * desktop client, any viewport. + * + * Every open window pushes one same-URL history entry tagged with its key. + * The popstate handler decides what to do from the state it LANDS on: + * + * - a key that is still registered → the user backed over the windows above + * it, so close everything above that key; + * - a key that is no longer registered → a phantom entry whose owner was + * unmounted without a matching back(); skip it silently with one more + * back() so a dead entry never swallows a back press; + * - no key (the workspace base entry or another page) → close the topmost + * window. A close handler may veto by returning `false` (e.g. unsaved + * files), in which case the entry is re-pushed to undo the traversal. + * + * Manual closes call history.back() themselves; the traversals those cause + * are swallowed via a counter rather than a boolean so rapid consecutive + * closes cannot leak a synthetic popstate into the user path. + */ +export function WorkspaceWindowHistoryProvider({ + children, +}: { + children: ReactNode +}) { + const entriesRef = useRef([]) + const byKeyRef = useRef(new Map()) + const ignoreNextPopCountRef = useRef(0) + + const open = useCallback((entry: Entry) => { + const existing = byKeyRef.current.get(entry.key) + if (existing) { + existing.close = entry.close + return existing + } + entriesRef.current.push(entry) + byKeyRef.current.set(entry.key, entry) + pushWindowState(entry.key) + return entry + }, []) + + const close = useCallback((entry: Entry, source: CloseSource) => { + if (!byKeyRef.current.has(entry.key)) return + byKeyRef.current.delete(entry.key) + entriesRef.current = entriesRef.current.filter( + (candidate) => candidate.key !== entry.key + ) + if (source === "manual") { + // Defer the traversal and re-check before backing out: a window + // opening in the same commit (e.g. tapping a file in the aux drawer + // closes the drawer and opens the file workspace together) pushes + // its own entry in a later effect of the same flush. history.back() + // is asynchronous — issued now, the traversal would run AFTER that + // push and land below the new entry, leaving the browser pointer + // misaligned with the live stack (the next real back press would + // then leave the page instead of closing that window). When this + // entry is no longer the current one, skip back(); its history + // entry becomes a phantom the popstate handler skips. + const key = entry.key + setTimeout(() => { + if (stateKeyOf(window.history.state) !== key) return + ignoreNextPopCountRef.current += 1 + window.history.back() + }, 0) + } + // "abandon" (owner unmounted) only cleans the memory stack. The browser + // entry becomes a phantom and is skipped by the popstate handler. + }, []) + + const attach = useCallback((entry: Entry) => { + const existing = byKeyRef.current.get(entry.key) + if (!existing) return null + existing.close = entry.close + return existing + }, []) + + useEffect(() => { + const onPopState = (event: PopStateEvent) => { + if (ignoreNextPopCountRef.current > 0) { + ignoreNextPopCountRef.current -= 1 + return + } + const landedKey = stateKeyOf(event.state) + if (landedKey !== null) { + if (!byKeyRef.current.has(landedKey)) { + // Phantom: the window owning this entry is gone. Skip the dead + // entry so back still does something visible. + window.history.back() + return + } + // Close every window above the one we landed on. + while (entriesRef.current.length > 0) { + const top = entriesRef.current[entriesRef.current.length - 1] + if (top.key === landedKey) break + const closed = top.close() + if (closed === false) { + pushWindowState(top.key) + break + } + top.popped = true + entriesRef.current.pop() + byKeyRef.current.delete(top.key) + } + return + } + // Landed on the workspace base entry or a page outside the workspace. + const entry = entriesRef.current[entriesRef.current.length - 1] + if (!entry) return + const closed = entry.close() + if (closed === false) { + pushWindowState(entry.key) + return + } + entry.popped = true + entriesRef.current.pop() + byKeyRef.current.delete(entry.key) + } + window.addEventListener("popstate", onPopState) + return () => window.removeEventListener("popstate", onPopState) + }, []) + + const value = useMemo( + () => ({ open, close, attach }), + [attach, close, open] + ) + return ( + + {children} + + ) +} + +export function useBrowserBackWindow({ + open, + onClose, + key, +}: { + open: boolean + onClose: () => boolean | void + key?: string +}) { + const context = useContext(HistoryContext) + const autoKey = useId() + const entryKey = key ?? autoKey + const closeRef = useRef(onClose) + const entryRef = useRef(null) + + useEffect(() => { + closeRef.current = onClose + }, [onClose]) + + // Register while open. Runs on every onClose change too, but `attach` + // finds the existing entry then and only refreshes its close callback — + // it never pushes a second history entry. Re-registering here also + // recovers the entry after a StrictMode simulated remount, which the + // abandon cleanup below removes. + useEffect(() => { + if (!context || !open) return + // The browser already dismissed this window; the parent just hasn't + // applied the closed state yet. Re-registering here would resurrect + // its history entry. + if (entryRef.current?.popped) return + const candidate: Entry = { + key: entryKey, + close: () => closeRef.current(), + } + entryRef.current = context.attach(candidate) ?? context.open(candidate) + }, [context, entryKey, onClose, open]) + + // Closed through the UI: consume the matching history entry. + useEffect(() => { + if (!context || open || !entryRef.current) return + const entry = entryRef.current + entryRef.current = null + context.close(entry, "manual") + }, [context, open]) + + // Unmounted while open: drop the memory entry. Its history entry stays + // behind as a phantom and is skipped on the next back press. + useEffect(() => { + return () => { + if (entryRef.current && context) { + context.close(entryRef.current, "abandon") + } + } + }, [context]) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 2c6c1627a9..ccc298c2a8 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "إغلاق \"{title}\" بدون حفظ؟", "confirmCloseOtherDirtyTabs": "إغلاق التبويبات الأخرى التي تحتوي تغييرات غير محفوظة؟", "confirmCloseAllDirtyTabs": "إغلاق جميع التبويبات التي تحتوي تغييرات غير محفوظة؟", + "confirmLeaveWorkspace": "مغادرة مساحة العمل؟", "unableLoadContent": "تعذر تحميل المحتوى.\n\n{message}", "previewRequestTimedOut": "انتهت مهلة طلب المعاينة", "diffRequestTimedOut": "انتهت مهلة طلب Diff", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 589e66ec3a..bd9902b9ec 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "„{title}“ ohne Speichern schließen?", "confirmCloseOtherDirtyTabs": "Andere Tabs mit ungespeicherten Änderungen schließen?", "confirmCloseAllDirtyTabs": "Alle Tabs mit ungespeicherten Änderungen schließen?", + "confirmLeaveWorkspace": "Arbeitsbereich verlassen?", "unableLoadContent": "Inhalt konnte nicht geladen werden.\n\n{message}", "previewRequestTimedOut": "Vorschauanfrage hat das Zeitlimit überschritten", "diffRequestTimedOut": "Diff-Anfrage hat das Zeitlimit überschritten", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2422c794bc..1fbf8e4058 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "Close \"{title}\" without saving?", "confirmCloseOtherDirtyTabs": "Close other tabs with unsaved changes?", "confirmCloseAllDirtyTabs": "Close all tabs with unsaved changes?", + "confirmLeaveWorkspace": "Leave the workspace?", "unableLoadContent": "Unable to load content.\n\n{message}", "previewRequestTimedOut": "Preview request timed out", "diffRequestTimedOut": "Diff request timed out", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 73995a8a51..1540e1b719 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "¿Cerrar \"{title}\" sin guardar?", "confirmCloseOtherDirtyTabs": "¿Cerrar otras pestañas con cambios sin guardar?", "confirmCloseAllDirtyTabs": "¿Cerrar todas las pestañas con cambios sin guardar?", + "confirmLeaveWorkspace": "¿Salir del espacio de trabajo?", "unableLoadContent": "No se puede cargar el contenido.\n\n{message}", "previewRequestTimedOut": "La solicitud de vista previa agotó el tiempo", "diffRequestTimedOut": "La solicitud de Diff agotó el tiempo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 88be70d87d..5e6b3c0664 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "Fermer « {title} » sans enregistrer ?", "confirmCloseOtherDirtyTabs": "Fermer les autres onglets avec des modifications non enregistrées ?", "confirmCloseAllDirtyTabs": "Fermer tous les onglets avec des modifications non enregistrées ?", + "confirmLeaveWorkspace": "Quitter l'espace de travail ?", "unableLoadContent": "Impossible de charger le contenu.\n\n{message}", "previewRequestTimedOut": "La requête de prévisualisation a expiré", "diffRequestTimedOut": "La requête Diff a expiré", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f4842f61c6..218c1231c1 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "保存せずに「{title}」を閉じますか?", "confirmCloseOtherDirtyTabs": "未保存の変更がある他のタブを閉じますか?", "confirmCloseAllDirtyTabs": "未保存の変更があるすべてのタブを閉じますか?", + "confirmLeaveWorkspace": "ワークスペースから移動しますか?", "unableLoadContent": "内容を読み込めません。\n\n{message}", "previewRequestTimedOut": "プレビュー要求がタイムアウトしました", "diffRequestTimedOut": "Diff 要求がタイムアウトしました", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e96bbbcc37..a25ab311c2 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "저장하지 않고 \"{title}\" 탭을 닫을까요?", "confirmCloseOtherDirtyTabs": "저장되지 않은 변경 사항이 있는 다른 탭을 닫을까요?", "confirmCloseAllDirtyTabs": "저장되지 않은 변경 사항이 있는 모든 탭을 닫을까요?", + "confirmLeaveWorkspace": "워크스페이스를 떠나시겠습니까?", "unableLoadContent": "콘텐츠를 불러올 수 없습니다.\n\n{message}", "previewRequestTimedOut": "미리보기 요청 시간이 초과되었습니다", "diffRequestTimedOut": "Diff 요청 시간이 초과되었습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f58b104049..8b81f7aa54 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "Fechar \"{title}\" sem salvar?", "confirmCloseOtherDirtyTabs": "Fechar outras abas com alterações não salvas?", "confirmCloseAllDirtyTabs": "Fechar todas as abas com alterações não salvas?", + "confirmLeaveWorkspace": "Sair do espaço de trabalho?", "unableLoadContent": "Não foi possível carregar o conteúdo.\n\n{message}", "previewRequestTimedOut": "A solicitação de preview expirou", "diffRequestTimedOut": "A solicitação de Diff expirou", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3147e1a83f..f91aa01e94 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "文件“{title}”有未保存更改,确定关闭吗?", "confirmCloseOtherDirtyTabs": "其它标签页有未保存更改,确定关闭吗?", "confirmCloseAllDirtyTabs": "存在未保存更改,确定关闭全部标签页吗?", + "confirmLeaveWorkspace": "要离开工作区吗?", "unableLoadContent": "无法加载内容。\n\n{message}", "previewRequestTimedOut": "预览请求超时", "diffRequestTimedOut": "Diff 请求超时", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b6a39ef54e..4e72a67449 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2763,6 +2763,7 @@ "confirmCloseDirtyTab": "檔案「{title}」有未儲存變更,確定關閉嗎?", "confirmCloseOtherDirtyTabs": "其他分頁有未儲存變更,確定關閉嗎?", "confirmCloseAllDirtyTabs": "存在未儲存變更,確定關閉全部分頁嗎?", + "confirmLeaveWorkspace": "要離開工作區嗎?", "unableLoadContent": "無法載入內容。\n\n{message}", "previewRequestTimedOut": "預覽請求逾時", "diffRequestTimedOut": "Diff 請求逾時",