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 請求逾時",