Files
codeg/src/contexts/workspace-context.tsx
2026-03-25 14:26:26 +08:00

1144 lines
31 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import {
gitDiff,
gitDiffWithBranch,
gitIsTracked,
gitShowDiff,
gitShowFile,
readFileBase64,
readFileForEdit,
readFilePreview,
saveFileContent,
} from "@/lib/api"
import { languageFromPath } from "@/lib/language-detect"
import {
loadPersistedWorkspaceMode,
savePersistedWorkspaceMode,
} from "@/lib/workspace-mode-storage"
export type WorkspaceMode = "conversation" | "fusion" | "files"
export type WorkspacePane = "conversation" | "files"
const DEFAULT_WORKSPACE_MODE: WorkspaceMode = "conversation"
type FileWorkspaceTabKind = "file" | "diff" | "rich-diff"
type FileSaveState = "idle" | "saving" | "error"
type LineEnding = "lf" | "crlf" | "mixed" | "none"
export interface FileWorkspaceTab {
id: string
kind: FileWorkspaceTabKind
title: string
description: string | null
path: string | null
language: string
content: string
loading: boolean
originalContent?: string
modifiedContent?: string
gitBaseContent?: string
savedContent?: string
isDirty?: boolean
etag?: string | null
mtimeMs?: number | null
readonly?: boolean
lineEnding?: LineEnding
saveState?: FileSaveState
saveError?: string | null
}
interface WorkspaceContextValue {
mode: WorkspaceMode
activePane: WorkspacePane
setMode: (mode: WorkspaceMode) => void
setActivePane: (pane: WorkspacePane) => void
activateConversationPane: () => void
activateFilePane: () => void
fileTabs: FileWorkspaceTab[]
activeFileTabId: string | null
activeFileTab: FileWorkspaceTab | null
activeFilePath: string | null
switchFileTab: (tabId: string) => void
closeFileTab: (tabId: string) => void
closeOtherFileTabs: (tabId: string) => void
closeAllFileTabs: () => void
reorderFileTabs: (tabs: FileWorkspaceTab[]) => void
openFilePreview: (path: string, options?: { line?: number }) => Promise<void>
pendingFileReveal: {
requestId: number
path: string
line: number
} | null
consumePendingFileReveal: (requestId: number) => void
openWorkingTreeDiff: (
path?: string,
options?: { mode?: "auto" | "unified" | "overview" }
) => Promise<void>
openBranchDiff: (
branch: string,
path?: string,
options?: { mode?: "default" | "overview" }
) => Promise<void>
openCommitDiff: (
commit: string,
path?: string,
message?: string
) => Promise<void>
openSessionFileDiff: (
filePath: string,
diffContent: string,
groupLabel: string
) => void
openExternalConflictDiff: (
filePath: string,
diskContent: string,
unsavedContent: string
) => void
updateActiveFileContent: (content: string) => void
saveActiveFile: (options?: { force?: boolean }) => Promise<boolean>
reloadActiveFile: () => Promise<void>
previewFileTabIds: Set<string>
toggleFileTabPreview: (tabId: string) => void
}
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null)
function normalizePath(path: string): string {
return path.replace(/\\/g, "/")
}
function fileName(path: string): string {
return path.split("/").pop() || path
}
function isDirtyFileTab(tab: FileWorkspaceTab): boolean {
return tab.kind === "file" && Boolean(tab.isDirty)
}
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"svg",
"webp",
"bmp",
"ico",
])
const IMAGE_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
}
function isImageFile(path: string): boolean {
const ext = path.split(".").pop()?.toLowerCase() ?? ""
return IMAGE_EXTENSIONS.has(ext)
}
function loadingTab(
id: string,
kind: FileWorkspaceTabKind,
title: string,
description: string | null,
path: string | null,
language: string
): FileWorkspaceTab {
return {
id,
kind,
title,
description,
path,
language,
content: "",
loading: true,
savedContent: "",
isDirty: false,
etag: null,
mtimeMs: null,
readonly: kind !== "file",
lineEnding: "none",
saveState: "idle",
saveError: null,
}
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(timeoutMessage))
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timer) clearTimeout(timer)
}
}
interface WorkspaceProviderProps {
children: ReactNode
}
export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
const t = useTranslations("Folder.workspaceContext")
const { folder, folderId } = useFolderContext()
const folderPath = folder?.path
const storageKey = useMemo(
() => `folder:${folderId}:workspace-mode`,
[folderId]
)
const [mode, setModeState] = useState<WorkspaceMode>(DEFAULT_WORKSPACE_MODE)
const [activePane, setActivePaneState] =
useState<WorkspacePane>("conversation")
const [restored, setRestored] = useState(false)
const [fileTabs, setFileTabs] = useState<FileWorkspaceTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const [pendingFileReveal, setPendingFileReveal] = useState<{
requestId: number
path: string
line: number
} | null>(null)
const [previewFileTabIds, setPreviewFileTabIds] = useState<Set<string>>(
new Set()
)
const fileTabsRef = useRef<FileWorkspaceTab[]>([])
const fileRevealRequestIdRef = useRef(0)
useEffect(() => {
fileTabsRef.current = fileTabs
}, [fileTabs])
useEffect(() => {
const storedMode = loadPersistedWorkspaceMode(storageKey)
const nextMode = (storedMode ?? DEFAULT_WORKSPACE_MODE) as WorkspaceMode
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setModeState(nextMode)
if (nextMode === "conversation" || nextMode === "files") {
setActivePaneState(nextMode)
}
setRestored(true)
}, [storageKey])
useEffect(() => {
if (!restored) return
savePersistedWorkspaceMode(storageKey, mode)
}, [mode, restored, storageKey])
const setModeSafe = useCallback((nextMode: WorkspaceMode) => {
setModeState(nextMode)
if (nextMode === "conversation" || nextMode === "files") {
setActivePaneState(nextMode)
}
}, [])
const setActivePane = useCallback((nextPane: WorkspacePane) => {
setActivePaneState((prev) => (prev === nextPane ? prev : nextPane))
}, [])
const activateConversationPane = useCallback(() => {
setActivePaneState((prev) =>
prev === "conversation" ? prev : "conversation"
)
setModeState((prev) => (prev === "fusion" ? prev : "conversation"))
}, [])
const activateFilePane = useCallback(() => {
setActivePaneState((prev) => (prev === "files" ? prev : "files"))
setModeState((prev) => (prev === "fusion" ? prev : "files"))
}, [])
const upsertLoadingTab = useCallback(
(nextTab: FileWorkspaceTab) => {
setFileTabs((prev) => {
const idx = prev.findIndex((tab) => tab.id === nextTab.id)
if (idx < 0) {
return [...prev, nextTab]
}
const updated = [...prev]
updated[idx] = {
...updated[idx],
...nextTab,
}
return updated
})
setActiveFileTabId(nextTab.id)
activateFilePane()
},
[activateFilePane]
)
const resolveTab = useCallback(
(tabId: string, content: string, loading = false) => {
setFileTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? {
...tab,
content,
loading,
}
: tab
)
)
},
[]
)
const rejectTab = useCallback(
(tabId: string, errorMessage: string) => {
resolveTab(
tabId,
t("unableLoadContent", { message: errorMessage }),
false
)
setFileTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? {
...tab,
saveState: "error",
saveError: errorMessage,
}
: tab
)
)
},
[resolveTab, t]
)
const resolveRichDiffTab = useCallback(
(
tabId: string,
originalContent: string,
modifiedContent: string,
loading = false
) => {
setFileTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? { ...tab, originalContent, modifiedContent, content: "", loading }
: tab
)
)
},
[]
)
const consumePendingFileReveal = useCallback((requestId: number) => {
setPendingFileReveal((prev) =>
prev && prev.requestId === requestId ? null : prev
)
}, [])
const openFilePreview = useCallback(
async (rawPath: string, options?: { line?: number }) => {
if (!folderPath) return
const path = normalizePath(rawPath)
const requestedLine =
typeof options?.line === "number" && Number.isFinite(options.line)
? Math.max(1, Math.floor(options.line))
: null
if (requestedLine) {
fileRevealRequestIdRef.current += 1
setPendingFileReveal({
requestId: fileRevealRequestIdRef.current,
path,
line: requestedLine,
})
} else {
setPendingFileReveal(null)
}
const tabId = `file:${path}`
const image = isImageFile(path)
upsertLoadingTab(
loadingTab(
tabId,
"file",
fileName(path),
path,
path,
image ? "image" : languageFromPath(path)
)
)
if (image) {
try {
const absPath = `${folderPath}/${path}`
const ext = path.split(".").pop()?.toLowerCase() ?? ""
const mime = IMAGE_MIME[ext] ?? "image/png"
const b64 = await withTimeout(
readFileBase64(absPath),
15_000,
t("previewRequestTimedOut")
)
setFileTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? {
...tab,
content: `data:${mime};base64,${b64}`,
readonly: true,
loading: false,
saveState: "idle",
saveError: null,
}
: tab
)
)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
return
}
try {
const [result, gitBaseContent] = await withTimeout(
Promise.all([
readFileForEdit(folderPath, path),
(async () => {
const tracked = await gitIsTracked(folderPath, path).catch(
() => false
)
if (!tracked) return undefined
return gitShowFile(folderPath, path).catch(() => "")
})(),
]),
15_000,
t("previewRequestTimedOut")
)
setFileTabs((prev) =>
prev.map((tab) =>
tab.id === tabId
? {
...tab,
content: result.content,
gitBaseContent,
savedContent: result.content,
isDirty: false,
etag: result.etag,
mtimeMs: result.mtime_ms,
readonly: result.readonly,
lineEnding: result.line_ending,
saveState: "idle",
saveError: null,
loading: false,
}
: tab
)
)
} catch (error) {
if (requestedLine) {
setPendingFileReveal((prev) =>
prev && prev.path === path ? null : prev
)
}
rejectTab(tabId, error instanceof Error ? error.message : String(error))
}
},
[folderPath, rejectTab, t, upsertLoadingTab]
)
const openWorkingTreeDiff = useCallback(
async (
rawPath?: string,
options?: { mode?: "auto" | "unified" | "overview" }
) => {
if (!folderPath) return
if (!rawPath) {
const tabId = "diff:working:all"
const title = t("diffTitleWorkspace")
const description = t("diffDescriptionWorkingTree")
upsertLoadingTab(
loadingTab(tabId, "diff", title, description, null, "diff")
)
try {
const result = await withTimeout(
gitDiff(folderPath),
20_000,
t("diffRequestTimedOut")
)
resolveTab(tabId, result || t("noChanges"), false)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
return
}
const path = normalizePath(rawPath)
const mode = options?.mode ?? "auto"
if (mode === "overview") {
const isRoot = path === "."
const displayPath = isRoot ? folderPath : path
const encodedPath = encodeURIComponent(path)
const tabId = `diff:working-overview:${encodedPath}`
const title = t("diffTitleFile", {
name: fileName(displayPath ?? path),
})
const description = displayPath ?? path
upsertLoadingTab(
loadingTab(tabId, "diff", title, description, path, "diff")
)
try {
const result = await withTimeout(
gitDiff(folderPath, path),
20_000,
t("diffRequestTimedOut")
)
resolveTab(tabId, result || t("noChanges"), false)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
return
}
if (mode === "unified") {
const tabId = `diff:working:${path}:unified`
const title = t("diffTitleFile", { name: fileName(path) })
const description = path
upsertLoadingTab(
loadingTab(tabId, "diff", title, description, path, "diff")
)
try {
const result = await withTimeout(
gitDiff(folderPath, path),
20_000,
t("diffRequestTimedOut")
)
resolveTab(tabId, result || t("noChanges"), false)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
return
}
const tabId = `diff:working:${path}`
const title = t("diffTitleFile", { name: fileName(path) })
const description = path
const lang = languageFromPath(path)
upsertLoadingTab(
loadingTab(tabId, "rich-diff", title, description, path, lang)
)
try {
const [originalContent, modifiedResult] = await withTimeout(
Promise.all([
gitShowFile(folderPath, path).catch(() => ""),
readFilePreview(folderPath, path).catch(() => ({
content: "",
path: "",
})),
]),
20_000,
t("diffRequestTimedOut")
)
resolveRichDiffTab(tabId, originalContent, modifiedResult.content)
} catch (error) {
rejectTab(tabId, error instanceof Error ? error.message : String(error))
}
},
[folderPath, rejectTab, resolveTab, resolveRichDiffTab, t, upsertLoadingTab]
)
const openBranchDiff = useCallback(
async (
branch: string,
rawPath?: string,
options?: { mode?: "default" | "overview" }
) => {
if (!folderPath) return
const targetBranch = branch.trim()
if (!targetBranch) return
const path = rawPath ? normalizePath(rawPath) : null
const mode = options?.mode ?? "default"
const encodedBranch = encodeURIComponent(targetBranch)
const encodedPath = encodeURIComponent(path ?? "all")
const tabId =
mode === "overview"
? `diff:branch-overview:${encodedBranch}:${encodedPath}`
: `diff:branch:${targetBranch}:${path ?? "all"}`
const title = path
? t("compareTitleFile", { name: fileName(path) })
: t("compareTitleBranch", { branch: targetBranch })
const description = path
? t("compareDescriptionPath", { path, branch: targetBranch })
: t("compareDescriptionBranch", { branch: targetBranch })
if (mode !== "overview" && path) {
const lang = languageFromPath(path)
upsertLoadingTab(
loadingTab(tabId, "rich-diff", title, description, path, lang)
)
try {
const [originalContent, modifiedResult] = await withTimeout(
Promise.all([
gitShowFile(folderPath, path, targetBranch).catch(() => ""),
readFilePreview(folderPath, path).catch(() => ({
content: "",
path: "",
})),
]),
20_000,
t("branchCompareRequestTimedOut")
)
resolveRichDiffTab(tabId, originalContent, modifiedResult.content)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
return
}
upsertLoadingTab(
loadingTab(tabId, "diff", title, description, path, "diff")
)
try {
const result = await withTimeout(
gitDiffWithBranch(folderPath, targetBranch, path ?? undefined),
20_000,
t("branchCompareRequestTimedOut")
)
resolveTab(tabId, result || t("noChanges"), false)
} catch (error) {
rejectTab(tabId, error instanceof Error ? error.message : String(error))
}
},
[folderPath, rejectTab, resolveRichDiffTab, resolveTab, t, upsertLoadingTab]
)
const openCommitDiff = useCallback(
async (commit: string, rawPath?: string, message?: string) => {
if (!folderPath) return
const path = rawPath ? normalizePath(rawPath) : null
const tabId = `diff:commit:${commit}:${path ?? "all"}`
const title = path
? t("diffTitleCommitFile", {
name: fileName(path),
hash: commit.slice(0, 7),
})
: t("diffTitleCommit", { hash: commit.slice(0, 7) })
const description = path
? t("diffDescriptionCommitPath", { path, commit })
: message || t("diffDescriptionCommit", { commit })
if (path) {
const lang = languageFromPath(path)
upsertLoadingTab(
loadingTab(tabId, "rich-diff", title, description, path, lang)
)
try {
const [originalContent, modifiedContent] = await withTimeout(
Promise.all([
gitShowFile(folderPath, path, `${commit}~1`).catch(() => ""),
gitShowFile(folderPath, path, commit).catch(() => ""),
]),
20_000,
t("commitDiffRequestTimedOut")
)
resolveRichDiffTab(tabId, originalContent, modifiedContent)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
} else {
upsertLoadingTab(
loadingTab(tabId, "diff", title, description, path, "diff")
)
try {
const result = await withTimeout(
gitShowDiff(folderPath, commit, undefined),
20_000,
t("commitDiffRequestTimedOut")
)
resolveTab(tabId, result || t("noDiffOutput"), false)
} catch (error) {
rejectTab(
tabId,
error instanceof Error ? error.message : String(error)
)
}
}
},
[folderPath, rejectTab, resolveTab, resolveRichDiffTab, t, upsertLoadingTab]
)
const openSessionFileDiff = useCallback(
(filePath: string, diffContent: string, groupLabel: string) => {
const path = normalizePath(filePath)
const tabId = `diff:session:${groupLabel}:${path}`
const title = t("diffTitleFile", { name: fileName(path) })
const description = `${path} · ${groupLabel}`
const tab: FileWorkspaceTab = {
id: tabId,
kind: "diff",
title,
description,
path: null,
language: "diff",
content: diffContent,
loading: false,
}
upsertLoadingTab(tab)
},
[t, upsertLoadingTab]
)
const openExternalConflictDiff = useCallback(
(filePath: string, diskContent: string, unsavedContent: string) => {
const path = normalizePath(filePath)
const tabId = `diff:external-conflict:${path}`
const title = t("diffTitleConflictFile", { name: fileName(path) })
const description = t("diffDescriptionConflict", { path })
const language = languageFromPath(path)
const tab: FileWorkspaceTab = {
id: tabId,
kind: "rich-diff",
title,
description,
path,
language,
content: "",
loading: false,
originalContent: diskContent,
modifiedContent: unsavedContent,
}
upsertLoadingTab(tab)
},
[t, upsertLoadingTab]
)
const updateActiveFileContent = useCallback(
(content: string) => {
if (!activeFileTabId) return
setFileTabs((prev) =>
prev.map((tab) => {
if (tab.id !== activeFileTabId || tab.kind !== "file") return tab
if (tab.loading || tab.readonly) return tab
if (tab.content === content) return tab
const savedContent = tab.savedContent ?? ""
return {
...tab,
content,
isDirty: content !== savedContent,
saveState: tab.saveState === "saving" ? "saving" : "idle",
saveError: null,
}
})
)
},
[activeFileTabId]
)
const saveFileTab = useCallback(
async (tabId: string, options?: { force?: boolean }): Promise<boolean> => {
if (!folderPath) return false
const tab = fileTabsRef.current.find(
(candidate) => candidate.id === tabId
)
if (!tab || tab.kind !== "file") return false
if (tab.loading || tab.readonly) return false
if (!tab.path) return false
if (!tab.isDirty) return true
const contentAtSaveStart = tab.content
const expectedEtag = options?.force ? null : (tab.etag ?? null)
setFileTabs((prev) =>
prev.map((candidate) =>
candidate.id === tabId
? {
...candidate,
saveState: "saving",
saveError: null,
}
: candidate
)
)
try {
const result = await withTimeout(
saveFileContent(
folderPath,
tab.path,
contentAtSaveStart,
expectedEtag
),
20_000,
t("saveRequestTimedOut")
)
setFileTabs((prev) =>
prev.map((candidate) => {
if (candidate.id !== tabId || candidate.kind !== "file") {
return candidate
}
const savedContent = contentAtSaveStart
return {
...candidate,
etag: result.etag,
mtimeMs: result.mtime_ms,
readonly: result.readonly,
lineEnding: result.line_ending,
savedContent,
isDirty: candidate.content !== savedContent,
saveState: "idle",
saveError: null,
}
})
)
return true
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setFileTabs((prev) =>
prev.map((candidate) =>
candidate.id === tabId
? {
...candidate,
saveState: "error",
saveError: message,
}
: candidate
)
)
return false
}
},
[folderPath, t]
)
const saveActiveFile = useCallback(
async (options?: { force?: boolean }) => {
if (!activeFileTabId) return false
return saveFileTab(activeFileTabId, options)
},
[activeFileTabId, saveFileTab]
)
const reloadFileTab = useCallback(
async (tabId: string) => {
if (!folderPath) return
const tab = fileTabsRef.current.find(
(candidate) => candidate.id === tabId
)
if (!tab || tab.kind !== "file" || !tab.path) return
const tabPath = tab.path
setFileTabs((prev) =>
prev.map((candidate) =>
candidate.id === tabId
? {
...candidate,
loading: true,
saveError: null,
saveState: "idle",
}
: candidate
)
)
try {
const [result, gitBaseContent] = await withTimeout(
Promise.all([
readFileForEdit(folderPath, tabPath),
(async () => {
const tracked = await gitIsTracked(folderPath, tabPath).catch(
() => false
)
if (!tracked) return undefined
return gitShowFile(folderPath, tabPath).catch(() => "")
})(),
]),
15_000,
t("reloadRequestTimedOut")
)
setFileTabs((prev) =>
prev.map((candidate) =>
candidate.id === tabId
? {
...candidate,
content: result.content,
gitBaseContent,
savedContent: result.content,
isDirty: false,
etag: result.etag,
mtimeMs: result.mtime_ms,
readonly: result.readonly,
lineEnding: result.line_ending,
saveState: "idle",
saveError: null,
loading: false,
}
: candidate
)
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setFileTabs((prev) =>
prev.map((candidate) =>
candidate.id === tabId
? {
...candidate,
loading: false,
saveState: "error",
saveError: message,
}
: candidate
)
)
}
},
[folderPath, t]
)
const reloadActiveFile = useCallback(async () => {
if (!activeFileTabId) return
await reloadFileTab(activeFileTabId)
}, [activeFileTabId, reloadFileTab])
const switchFileTab = useCallback(
(tabId: string) => {
if (activeFileTabId && activeFileTabId !== tabId) {
void saveFileTab(activeFileTabId)
}
setActiveFileTabId(tabId)
activateFilePane()
},
[activeFileTabId, activateFilePane, saveFileTab]
)
const closeFileTab = useCallback(
(tabId: string) => {
setFileTabs((prev) => {
const idx = prev.findIndex((tab) => tab.id === tabId)
if (idx < 0) return prev
const tab = prev[idx]
if (isDirtyFileTab(tab)) {
const confirmed = window.confirm(
t("confirmCloseDirtyTab", { title: tab.title })
)
if (!confirmed) return prev
}
const next = prev.filter((candidate) => candidate.id !== tabId)
setActiveFileTabId((current) => {
if (current !== tabId) return current
if (next.length === 0) {
activateConversationPane()
return null
}
const nextIdx = Math.min(idx, next.length - 1)
return next[nextIdx].id
})
setPreviewFileTabIds((prev) => {
if (!prev.has(tabId)) return prev
const updated = new Set(prev)
updated.delete(tabId)
return updated
})
return next
})
},
[activateConversationPane, t]
)
const closeOtherFileTabs = useCallback(
(tabId: string) => {
setFileTabs((prev) => {
const remaining = prev.filter((tab) => tab.id === tabId)
if (remaining.length === 0) return prev
const closingTabs = prev.filter((tab) => tab.id !== tabId)
if (closingTabs.some(isDirtyFileTab)) {
const confirmed = window.confirm(t("confirmCloseOtherDirtyTabs"))
if (!confirmed) return prev
}
setActiveFileTabId(tabId)
activateFilePane()
return remaining
})
},
[activateFilePane, t]
)
const closeAllFileTabs = useCallback(() => {
setFileTabs((prev) => {
if (prev.some(isDirtyFileTab)) {
const confirmed = window.confirm(t("confirmCloseAllDirtyTabs"))
if (!confirmed) return prev
}
setActiveFileTabId(null)
setPreviewFileTabIds(new Set())
activateConversationPane()
return []
})
}, [activateConversationPane, t])
const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => {
setFileTabs(tabs)
}, [])
const activeFileTab = useMemo(
() => fileTabs.find((tab) => tab.id === activeFileTabId) ?? null,
[fileTabs, activeFileTabId]
)
const activeFilePath = activeFileTab?.path ?? null
const toggleFileTabPreview = useCallback((tabId: string) => {
setPreviewFileTabIds((prev) => {
const next = new Set(prev)
if (next.has(tabId)) {
next.delete(tabId)
} else {
next.add(tabId)
}
return next
})
}, [])
const value = useMemo<WorkspaceContextValue>(
() => ({
mode,
activePane,
setMode: setModeSafe,
setActivePane,
activateConversationPane,
activateFilePane,
fileTabs,
activeFileTabId,
activeFileTab,
activeFilePath,
switchFileTab,
closeFileTab,
closeOtherFileTabs,
closeAllFileTabs,
reorderFileTabs,
openFilePreview,
pendingFileReveal,
consumePendingFileReveal,
openWorkingTreeDiff,
openBranchDiff,
openCommitDiff,
openSessionFileDiff,
openExternalConflictDiff,
updateActiveFileContent,
saveActiveFile,
reloadActiveFile,
previewFileTabIds,
toggleFileTabPreview,
}),
[
mode,
activePane,
setModeSafe,
setActivePane,
activateConversationPane,
activateFilePane,
fileTabs,
activeFileTabId,
activeFileTab,
activeFilePath,
switchFileTab,
closeFileTab,
closeOtherFileTabs,
closeAllFileTabs,
reorderFileTabs,
openFilePreview,
pendingFileReveal,
consumePendingFileReveal,
openWorkingTreeDiff,
openBranchDiff,
openCommitDiff,
openSessionFileDiff,
openExternalConflictDiff,
updateActiveFileContent,
saveActiveFile,
reloadActiveFile,
previewFileTabIds,
toggleFileTabPreview,
]
)
return (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
)
}
export function useWorkspaceContext() {
const ctx = useContext(WorkspaceContext)
if (!ctx) {
throw new Error("useWorkspaceContext must be used within WorkspaceProvider")
}
return ctx
}