feat(folder): unify workspace state streaming for tree and git panels

Introduce a shared workspace-state backend stream with snapshot/delta APIs for file tree and git changes.

Migrate both aux panels to a common frontend workspace store with lifecycle-safe stream handling.

Apply batched watch throttling, path-aware git refresh gating, no-op delta suppression, and bounded history compaction to improve runtime stability.
This commit is contained in:
xintaofei
2026-04-14 22:26:36 +08:00
parent 90e8bb645a
commit b5e8fd8acb
15 changed files with 2856 additions and 1419 deletions

View File

@@ -8,23 +8,23 @@ import {
useState,
type ReactNode,
} from "react"
import { revealItemInDir, subscribe } from "@/lib/platform"
import { revealItemInDir } from "@/lib/platform"
import ignore from "ignore"
import { Check, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { toErrorMessage } from "@/lib/app-error"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useTabContext } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import {
createFileTreeEntry,
deleteFileTreeEntry,
gitAddFiles,
getGitBranch,
getFileTree,
getGitBranch,
gitListAllBranches,
gitRollbackFile,
gitStatus,
@@ -33,17 +33,10 @@ import {
openCommitWindow,
renameFileTreeEntry,
saveFileCopy,
startFileTreeWatch,
stopFileTreeWatch,
} from "@/lib/api"
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
import { ScrollArea } from "@/components/ui/scroll-area"
import type {
FileTreeChangedEvent,
FileTreeNode,
GitBranchList,
GitStatusEntry,
} from "@/lib/types"
import type { FileTreeNode, GitBranchList, GitStatusEntry } from "@/lib/types"
import {
FileTree,
FileTreeFolder,
@@ -101,6 +94,7 @@ function baseName(path: string): string {
const FILE_TREE_ROOT_PATH = "__workspace_root__"
const GITIGNORE_MUTED_CLASS = "text-muted-foreground/55"
const FILE_TREE_LAZY_DEBUG_LOG = process.env.NODE_ENV === "development"
interface FileActionTarget {
kind: "file" | "dir"
@@ -135,9 +129,78 @@ function normalizeComparePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "")
}
function isGitMetadataPath(path: string): boolean {
const normalized = normalizeComparePath(path)
return normalized === ".git" || normalized.startsWith(".git/")
function logFileTreeLazyDebug(
message: string,
payload?: Record<string, unknown>
) {
if (!FILE_TREE_LAZY_DEBUG_LOG) return
if (payload) {
console.info(`[FileTreeTab/lazy] ${message}`, payload)
return
}
console.info(`[FileTreeTab/lazy] ${message}`)
}
function logFileTreeWorkspaceDebug(
message: string,
payload?: Record<string, unknown>
) {
if (!FILE_TREE_LAZY_DEBUG_LOG) return
if (payload) {
console.info(`[FileTreeTab/workspace] ${message}`, payload)
return
}
console.info(`[FileTreeTab/workspace] ${message}`)
}
function prefixFileTreeNodePaths(
nodes: FileTreeNode[],
prefix: string
): FileTreeNode[] {
return nodes.map((node) => {
const nextPath = prefix ? `${prefix}/${node.path}` : node.path
if (node.kind === "file") {
return {
...node,
path: nextPath,
}
}
return {
...node,
path: nextPath,
children: prefixFileTreeNodePaths(node.children, nextPath),
}
})
}
function applyLazyTreeOverrides(
nodes: FileTreeNode[],
overrides: ReadonlyMap<string, FileTreeNode[]>
): FileTreeNode[] {
return nodes.map((node) => {
if (node.kind === "file") return node
const overrideChildren = overrides.get(node.path)
const baseChildren = overrideChildren ?? node.children
return {
...node,
children: applyLazyTreeOverrides(baseChildren, overrides),
}
})
}
function findDirectoryChildren(
nodes: FileTreeNode[],
targetPath: string
): FileTreeNode[] | null {
for (const node of nodes) {
if (node.kind !== "dir") continue
if (normalizeComparePath(node.path) === targetPath) {
return node.children
}
const nested = findDirectoryChildren(node.children, targetPath)
if (nested) return nested
}
return null
}
function classifyGitFileState(status: string): GitFileState | null {
@@ -180,11 +243,6 @@ function hasIgnoredAncestor(path: string, ignoredPaths: ReadonlySet<string>) {
}
}
function getRelativePathDepth(path: string): number {
if (!path) return 0
return path.split("/").filter(Boolean).length
}
type DirectoryGitAction = "add" | "rollback"
interface DirectoryGitCandidateEntry {
@@ -762,8 +820,7 @@ function RenderNode({
export function FileTreeTab() {
const t = useTranslations("Folder.fileTreeTab")
const tCommon = useTranslations("Folder.common")
const { activeTab, pendingRevealPath, consumePendingRevealPath } =
useAuxPanelContext()
const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext()
const { folder } = useFolderContext()
const { tabs, activeTabId } = useTabContext()
const { createTerminalInDirectory } = useTerminalContext()
@@ -775,6 +832,7 @@ export function FileTreeTab() {
openFilePreview,
openWorkingTreeDiff,
} = useWorkspaceContext()
const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
const [nodes, setNodes] = useState<FileTreeNode[]>([])
const [gitStatusByPath, setGitStatusByPath] = useState<Map<string, string>>(
new Map()
@@ -841,25 +899,21 @@ export function FileTreeTab() {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set([FILE_TREE_ROOT_PATH])
)
const [loadedTreeDepth, setLoadedTreeDepth] = useState(1)
const [gitignoreIgnoredPaths, setGitignoreIgnoredPaths] = useState<
Set<string>
>(new Set())
const isFileTreeTabActive = activeTab === "file_tree"
const activeFileTabRef = useRef(activeFileTab)
const filePathSetRef = useRef<Set<string>>(new Set())
const loadedTreeDepthRef = useRef(1)
const isFileTreeTabActiveRef = useRef(isFileTreeTabActive)
const pendingTreeRefreshRef = useRef(false)
const pendingTreeRefreshNeedsStatusRef = useRef(false)
const pendingStatusRefreshRef = useRef(false)
const treeRefreshNeedsStatusRef = useRef(false)
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
const previousWorkspaceSeqRef = useRef(0)
const previousExpandedPathsRef = useRef<Set<string>>(
new Set([FILE_TREE_ROOT_PATH])
)
const lazyLoadedChildrenByPathRef = useRef<Map<string, FileTreeNode[]>>(
new Map()
)
const treeRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set())
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
new Map()
)
useEffect(() => {
@@ -868,12 +922,14 @@ export function FileTreeTab() {
useEffect(() => {
setExpandedPaths(new Set([FILE_TREE_ROOT_PATH]))
loadedTreeDepthRef.current = 1
setLoadedTreeDepth(1)
previousExpandedPathsRef.current = new Set([FILE_TREE_ROOT_PATH])
setGitignoreIgnoredPaths(new Set())
setExternalConflictPrompt(null)
setSavingExternalConflictCopy(false)
lazyLoadedChildrenByPathRef.current.clear()
lazyLoadingDirPathsRef.current.clear()
externalConflictSignatureByPathRef.current.clear()
previousWorkspaceSeqRef.current = 0
}, [folder?.path])
// Handle pending reveal path: expand all ancestor directories once tree is loaded
@@ -909,10 +965,6 @@ export function FileTreeTab() {
)
}, [activeFileTab])
useEffect(() => {
loadedTreeDepthRef.current = loadedTreeDepth
}, [loadedTreeDepth])
const activeSessionTabId = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (!activeTab) return null
@@ -922,39 +974,6 @@ export function FileTreeTab() {
return activeTab.id
}, [tabs, activeTabId])
const applyGitStatusResult = useCallback(
(entries: { file: string; status: string }[]) => {
const nextStatusByPath = new Map<string, string>()
for (const entry of entries) {
const raw = normalizeGitStatusPath(entry.file)
if (!raw) continue
// Strip trailing slash (directory entries from -unormal)
const normalizedPath = raw.replace(/\/+$/, "")
if (!normalizedPath) continue
nextStatusByPath.set(normalizedPath, entry.status)
}
setGitEnabled(true)
setGitStatusByPath(nextStatusByPath)
},
[]
)
const refreshGitStatus = useCallback(async () => {
if (!folder?.path) {
setGitStatusByPath(new Map())
setGitEnabled(false)
return
}
try {
const result = await gitStatus(folder.path)
applyGitStatusResult(result)
} catch {
setGitEnabled(false)
setGitStatusByPath(new Map())
}
}, [applyGitStatusResult, folder?.path])
const fetchTree = useCallback(
async (options?: {
skipTree?: boolean
@@ -962,103 +981,140 @@ export function FileTreeTab() {
silent?: boolean
maxDepth?: number
}) => {
void options
if (!folder?.path) {
setNodes([])
loadedTreeDepthRef.current = 1
setLoadedTreeDepth(1)
setGitStatusByPath(new Map())
setGitEnabled(false)
setLoading(false)
setError(null)
return
}
const skipTree = options?.skipTree ?? false
const skipStatus = options?.skipStatus ?? false
const silent = options?.silent ?? false
const maxDepth = options?.maxDepth ?? loadedTreeDepthRef.current
if (!silent) setLoading(true)
setError(null)
let loadingReleased = false
try {
if (skipTree) {
if (!skipStatus) {
await refreshGitStatus()
}
return
}
if (skipStatus) {
const treeResult = await getFileTree(folder.path, maxDepth)
setNodes(treeResult)
setLoadedTreeDepth((prev) => {
const next = Math.max(prev, maxDepth)
loadedTreeDepthRef.current = next
return next
})
return
}
const treePromise = getFileTree(folder.path, maxDepth)
const gitStatusPromise = gitStatus(folder.path)
const treeResult = await treePromise
setNodes(treeResult)
setLoadedTreeDepth((prev) => {
const next = Math.max(prev, maxDepth)
loadedTreeDepthRef.current = next
return next
})
// Show file tree as soon as it's ready; git status can follow.
if (!silent) {
setLoading(false)
loadingReleased = true
}
try {
const gitStatusResult = await gitStatusPromise
applyGitStatusResult(gitStatusResult)
} catch {
setGitEnabled(false)
setGitStatusByPath(new Map())
}
} catch (e) {
setError(toErrorMessage(e))
} finally {
if (!silent && !loadingReleased) setLoading(false)
}
await workspaceState.requestResync("manual_refresh")
},
[applyGitStatusResult, folder?.path, refreshGitStatus]
[folder?.path, workspaceState]
)
useEffect(() => {
isFileTreeTabActiveRef.current = isFileTreeTabActive
if (!isFileTreeTabActive) return
if (pendingTreeRefreshRef.current) {
const needsStatus =
pendingTreeRefreshNeedsStatusRef.current ||
pendingStatusRefreshRef.current
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
void fetchTree({ silent: true, skipStatus: !needsStatus })
return
setNodes(
applyLazyTreeOverrides(
workspaceState.tree,
lazyLoadedChildrenByPathRef.current
)
)
const nextStatusByPath = new Map<string, string>()
for (const entry of workspaceState.git) {
nextStatusByPath.set(entry.path, entry.status)
}
setGitStatusByPath(nextStatusByPath)
setGitEnabled(true)
setLoading(
workspaceState.health === "resyncing" && workspaceState.seq === 0
)
setError(workspaceState.health === "degraded" ? workspaceState.error : null)
if (pendingStatusRefreshRef.current) {
pendingStatusRefreshRef.current = false
void fetchTree({ skipTree: true, silent: true })
}
}, [fetchTree, isFileTreeTabActive])
logFileTreeWorkspaceDebug("workspace state consumed", {
rootPath: folder?.path ?? "",
seq: workspaceState.seq,
health: workspaceState.health,
treeRoots: workspaceState.tree.length,
gitEntries: workspaceState.git.length,
lazyOverrideDirs: lazyLoadedChildrenByPathRef.current.size,
})
}, [
folder?.path,
workspaceState.error,
workspaceState.git,
workspaceState.health,
workspaceState.seq,
workspaceState.tree,
])
const loadDirectoryChildren = useCallback(
async (dirPath: string) => {
const rootPath = folder?.path
if (!rootPath) return
const normalizedDirPath = normalizeComparePath(dirPath)
if (!normalizedDirPath) return
if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) {
logFileTreeLazyDebug("skip cached", {
rootPath,
dirPath: normalizedDirPath,
})
return
}
if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) {
logFileTreeLazyDebug("skip in-flight", {
rootPath,
dirPath: normalizedDirPath,
})
return
}
const existingChildren = findDirectoryChildren(nodes, normalizedDirPath)
if (existingChildren && existingChildren.length > 0) {
lazyLoadedChildrenByPathRef.current.set(
normalizedDirPath,
existingChildren
)
logFileTreeLazyDebug("skip use existing children", {
rootPath,
dirPath: normalizedDirPath,
childrenCount: existingChildren.length,
})
return
}
lazyLoadingDirPathsRef.current.add(normalizedDirPath)
const startedAt = performance.now()
logFileTreeLazyDebug("request start", {
rootPath,
dirPath: normalizedDirPath,
})
try {
const subtree = await getFileTree(
joinFsPath(rootPath, normalizedDirPath),
1
)
const prefixed = prefixFileTreeNodePaths(subtree, normalizedDirPath)
lazyLoadedChildrenByPathRef.current.set(normalizedDirPath, prefixed)
setNodes((prev) =>
applyLazyTreeOverrides(prev, lazyLoadedChildrenByPathRef.current)
)
logFileTreeLazyDebug("request success", {
rootPath,
dirPath: normalizedDirPath,
childrenCount: prefixed.length,
durationMs: Math.round(performance.now() - startedAt),
})
} catch {
// Ignore lazy load failures and keep current collapsed/empty state.
logFileTreeLazyDebug("request failed", {
rootPath,
dirPath: normalizedDirPath,
durationMs: Math.round(performance.now() - startedAt),
})
} finally {
lazyLoadingDirPathsRef.current.delete(normalizedDirPath)
}
},
[folder?.path, nodes]
)
useEffect(() => {
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
treeRefreshNeedsStatusRef.current = false
}, [folder?.path])
const previousExpanded = previousExpandedPathsRef.current
for (const path of expandedPaths) {
if (path === FILE_TREE_ROOT_PATH) continue
if (previousExpanded.has(path)) continue
logFileTreeLazyDebug("expanded path detected", {
rootPath: folder?.path ?? "",
dirPath: path,
})
void loadDirectoryChildren(path)
}
previousExpandedPathsRef.current = new Set(expandedPaths)
}, [expandedPaths, folder?.path, loadDirectoryChildren])
const filePathSet = useMemo(() => {
const paths = new Set<string>()
@@ -1100,15 +1156,6 @@ export function FileTreeTab() {
return Array.from(dirs)
}, [expandedPaths])
const desiredTreeDepth = useMemo(() => {
let nextDepth = 1
for (const path of expandedPaths) {
if (path === FILE_TREE_ROOT_PATH) continue
nextDepth = Math.max(nextDepth, getRelativePathDepth(path) + 1)
}
return nextDepth
}, [expandedPaths])
useEffect(() => {
filePathSetRef.current = filePathSet
}, [filePathSet])
@@ -1873,117 +1920,22 @@ export function FileTreeTab() {
[rootNodeName]
)
useEffect(() => {
if (!isFileTreeTabActive) return
void fetchTree()
}, [fetchTree, isFileTreeTabActive])
useEffect(() => {
if (!isFileTreeTabActive || !folder?.path) return
if (desiredTreeDepth <= loadedTreeDepth) return
void fetchTree({ silent: true, maxDepth: desiredTreeDepth })
}, [
desiredTreeDepth,
fetchTree,
folder?.path,
isFileTreeTabActive,
loadedTreeDepth,
])
useEffect(() => {
const rootPath = folder?.path
if (!rootPath) return
let unlisten: (() => void) | null = null
let disposed = false
let watchStarted = false
let watchReleased = false
const normalizedRootPath = normalizeComparePath(rootPath)
const releaseWatch = () => {
if (watchReleased) return
watchReleased = true
if (unlisten) {
unlisten()
unlisten = null
type ActiveFileChangeDecision =
| { kind: "none" }
| { kind: "reload"; path: string }
| {
kind: "conflict"
path: string
diskContent: string
unsavedContent: string
signature: string
}
if (watchStarted) {
void stopFileTreeWatch(rootPath)
}
}
const scheduleTreeRefresh = (refreshGitStatus: boolean) => {
if (!isFileTreeTabActiveRef.current) {
pendingTreeRefreshRef.current = true
pendingTreeRefreshNeedsStatusRef.current =
pendingTreeRefreshNeedsStatusRef.current || refreshGitStatus
if (refreshGitStatus) {
pendingStatusRefreshRef.current = false
}
return
}
treeRefreshNeedsStatusRef.current =
treeRefreshNeedsStatusRef.current || refreshGitStatus
if (treeRefreshTimerRef.current) {
clearTimeout(treeRefreshTimerRef.current)
}
treeRefreshTimerRef.current = setTimeout(() => {
const needsStatus = treeRefreshNeedsStatusRef.current
treeRefreshNeedsStatusRef.current = false
void fetchTree({ silent: true, skipStatus: !needsStatus })
}, 180)
}
const resolveActiveFileChangeDecision = useCallback(
async (path: string): Promise<ActiveFileChangeDecision> => {
const rootPath = folder?.path
if (!rootPath) return { kind: "none" }
const scheduleStatusRefresh = () => {
if (!isFileTreeTabActiveRef.current) {
if (pendingTreeRefreshRef.current) {
pendingTreeRefreshNeedsStatusRef.current = true
} else {
pendingStatusRefreshRef.current = true
}
return
}
if (statusRefreshTimerRef.current) {
clearTimeout(statusRefreshTimerRef.current)
}
statusRefreshTimerRef.current = setTimeout(() => {
void fetchTree({ skipTree: true, silent: true })
}, 120)
}
const getActiveChangedFilePath = (
changedPaths: string[],
fullReload: boolean
) => {
if (fullReload) return null
const currentTab = activeFileTabRef.current
if (!currentTab || currentTab.kind !== "file") return null
if (!currentTab.path || currentTab.loading) return null
const normalizedActivePath = normalizeComparePath(currentTab.path)
const activePathChanged = changedPaths.some(
(changedPath) =>
normalizeComparePath(changedPath) === normalizedActivePath
)
if (!activePathChanged) return null
return currentTab.path
}
type ActiveFileChangeDecision =
| { kind: "none" }
| { kind: "reload"; path: string }
| {
kind: "conflict"
path: string
diskContent: string
unsavedContent: string
signature: string
}
const resolveActiveFileChangeDecision = async (
path: string
): Promise<ActiveFileChangeDecision> => {
const currentTab = activeFileTabRef.current
if (!currentTab || currentTab.kind !== "file") return { kind: "none" }
if (
@@ -2034,131 +1986,59 @@ export function FileTreeTab() {
if (latestTab.loading) return { kind: "none" }
if (latestTab.isDirty) return { kind: "none" }
if (!knownTabEtag) return { kind: "reload", path }
// Fallback: if probe fails but tab is clean, reload to reflect latest disk state.
return { kind: "reload", path }
}
}
},
[folder?.path]
)
const setup = async () => {
try {
await startFileTreeWatch(rootPath)
watchStarted = true
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.watchStartFailed"), { description: message })
}
if (disposed) {
releaseWatch()
useEffect(() => {
const rootPath = folder?.path
if (!rootPath) return
const nextSeq = workspaceState.seq
if (nextSeq <= previousWorkspaceSeqRef.current) return
previousWorkspaceSeqRef.current = nextSeq
const currentTab = activeFileTabRef.current
if (!currentTab || currentTab.kind !== "file") return
if (!currentTab.path || currentTab.loading) return
const activePath = currentTab.path
void (async () => {
const decision = await resolveActiveFileChangeDecision(activePath)
if (decision.kind === "none") return
if (decision.kind === "reload") {
externalConflictSignatureByPathRef.current.delete(decision.path)
void openFilePreview(decision.path)
return
}
try {
const subscribedUnlisten = await subscribe<FileTreeChangedEvent>(
"folder://file-tree-changed",
(payload) => {
if (
normalizeComparePath(payload.root_path) !== normalizedRootPath
) {
return
}
const changedPaths = payload.changed_paths.map(normalizeComparePath)
const shouldRefreshGitStatus = payload.refresh_git_status ?? true
const nonGitChangedPaths = changedPaths.filter(
(path) => !isGitMetadataPath(path)
)
const onlyGitMetadataChanges =
changedPaths.length > 0 && nonGitChangedPaths.length === 0
const hasUnknownPath = nonGitChangedPaths.some(
(path) => !filePathSetRef.current.has(path)
)
const needsTreeRefresh =
payload.full_reload ||
(!onlyGitMetadataChanges &&
(payload.kind !== "modify" ||
nonGitChangedPaths.length === 0 ||
hasUnknownPath))
if (onlyGitMetadataChanges && !payload.full_reload) {
if (shouldRefreshGitStatus) {
scheduleStatusRefresh()
}
} else if (needsTreeRefresh) {
scheduleTreeRefresh(shouldRefreshGitStatus)
} else if (shouldRefreshGitStatus) {
scheduleStatusRefresh()
}
if (onlyGitMetadataChanges && !payload.full_reload) {
return
}
const changedActivePath = getActiveChangedFilePath(
nonGitChangedPaths,
payload.full_reload
)
if (!changedActivePath) return
void (async () => {
const decision =
await resolveActiveFileChangeDecision(changedActivePath)
if (decision.kind === "none") return
if (decision.kind === "reload") {
externalConflictSignatureByPathRef.current.delete(decision.path)
void openFilePreview(decision.path)
return
}
const shownSignature =
externalConflictSignatureByPathRef.current.get(decision.path)
if (shownSignature === decision.signature) return
externalConflictSignatureByPathRef.current.set(
decision.path,
decision.signature
)
setExternalConflictPrompt((current) => {
if (current?.signature === decision.signature) return current
return {
path: decision.path,
diskContent: decision.diskContent,
unsavedContent: decision.unsavedContent,
signature: decision.signature,
}
})
})()
}
)
if (disposed) {
subscribedUnlisten()
releaseWatch()
return
const shownSignature = externalConflictSignatureByPathRef.current.get(
decision.path
)
if (shownSignature === decision.signature) return
externalConflictSignatureByPathRef.current.set(
decision.path,
decision.signature
)
setExternalConflictPrompt((current) => {
if (current?.signature === decision.signature) return current
return {
path: decision.path,
diskContent: decision.diskContent,
unsavedContent: decision.unsavedContent,
signature: decision.signature,
}
unlisten = subscribedUnlisten
} catch (error) {
console.error("[FileTreeTab] failed to listen file watch event:", error)
}
}
void setup()
return () => {
disposed = true
if (treeRefreshTimerRef.current) {
clearTimeout(treeRefreshTimerRef.current)
treeRefreshTimerRef.current = null
}
treeRefreshNeedsStatusRef.current = false
if (statusRefreshTimerRef.current) {
clearTimeout(statusRefreshTimerRef.current)
statusRefreshTimerRef.current = null
}
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
releaseWatch()
}
}, [fetchTree, folder?.path, openFilePreview, t])
})
})()
}, [
folder?.path,
openFilePreview,
resolveActiveFileChangeDecision,
workspaceState.seq,
])
if (loading && nodes.length === 0) {
return (

View File

@@ -8,7 +8,6 @@ import {
useRef,
useState,
} from "react"
import { subscribe } from "@/lib/platform"
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@@ -35,20 +34,17 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Skeleton } from "@/components/ui/skeleton"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useFolderContext } from "@/contexts/folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import {
deleteFileTreeEntry,
gitDiff,
gitAddFiles,
gitRollbackFile,
gitStatus,
openCommitWindow,
startFileTreeWatch,
stopFileTreeWatch,
} from "@/lib/api"
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
import type { GitStatusEntry } from "@/lib/types"
import {
AlertDialog,
AlertDialogAction,
@@ -119,6 +115,19 @@ interface MutableChangeTreeDirNode {
const TRACKED_ROOT_PATH = "__working_tree_tracked_root__"
const UNTRACKED_ROOT_PATH = "__working_tree_untracked_root__"
const UNTRACKED_STATUS = "??"
const GIT_CHANGES_DEBUG_LOG = process.env.NODE_ENV === "development"
function logGitChangesDebug(
message: string,
payload?: Record<string, unknown>
) {
if (!GIT_CHANGES_DEBUG_LOG) return
if (payload) {
console.info(`[GitChangesTab/workspace] ${message}`, payload)
return
}
console.info(`[GitChangesTab/workspace] ${message}`)
}
type GitFileState =
| "untracked"
@@ -228,52 +237,6 @@ function filterDirectoryGitCandidates(
})
}
function normalizeDiffPath(rawPath: string): string | null {
const trimmed = rawPath.trim().replace(/^"|"$/g, "")
if (!trimmed || trimmed === "/dev/null") return null
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
return trimmed.slice(2).replace(/\\/g, "/")
}
return trimmed.replace(/\\/g, "/")
}
function parsePathFromDiffGitLine(line: string): string | null {
if (!line.startsWith("diff --git ")) return null
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
if (!match) return null
return normalizeDiffPath(match[2]) ?? normalizeDiffPath(match[1])
}
function parseDiffStatsMap(
diffText: string
): Map<string, { additions: number; deletions: number }> {
const stats = new Map<string, { additions: number; deletions: number }>()
let currentPath: string | null = null
for (const line of diffText.split("\n")) {
const nextPath = parsePathFromDiffGitLine(line)
if (nextPath) {
currentPath = nextPath
if (!stats.has(currentPath)) {
stats.set(currentPath, { additions: 0, deletions: 0 })
}
continue
}
if (!currentPath) continue
const current = stats.get(currentPath)
if (!current) continue
if (line.startsWith("+") && !line.startsWith("+++")) {
current.additions += 1
} else if (line.startsWith("-") && !line.startsWith("---")) {
current.deletions += 1
}
}
return stats
}
function toSortedTreeNodes(dir: MutableChangeTreeDirNode): ChangeTreeNode[] {
return Array.from(dir.children.values())
.map<ChangeTreeNode>((node) => {
@@ -420,47 +383,12 @@ function canOpenFile(status: string): boolean {
return !status.trim().toUpperCase().includes("D")
}
function shouldRefreshFromEvent(event: FileTreeChangedEvent): boolean {
const shouldRefreshGitStatus = event.refresh_git_status ?? true
if (!shouldRefreshGitStatus) return false
if (event.kind === "access") return false
return true
}
function toWorkingTreeChanges(
entries: GitStatusEntry[],
diffText: string
): WorkingTreeChange[] {
const stats = parseDiffStatsMap(diffText)
return entries
.map((entry) => {
const path = normalizeGitStatusPath(entry.file)
if (!path) return null
const diffStat = stats.get(path)
return {
path,
status: entry.status.trim() || "M",
additions: diffStat?.additions ?? 0,
deletions: diffStat?.deletions ?? 0,
}
})
.filter((change): change is WorkingTreeChange => change !== null)
.sort((left, right) =>
left.path.localeCompare(right.path, undefined, { sensitivity: "base" })
)
}
export function GitChangesTab() {
const t = useTranslations("Folder.gitChangesTab")
const tCommon = useTranslations("Folder.common")
const { folder } = useFolderContext()
const { activeTab } = useAuxPanelContext()
const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext()
const [changes, setChanges] = useState<WorkingTreeChange[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
const [expandedTrackedPaths, setExpandedTrackedPaths] = useState<Set<string>>(
new Set()
@@ -492,9 +420,6 @@ export function GitChangesTab() {
const hasHydratedTrackedPaths = useRef(false)
const hasHydratedUntrackedPaths = useRef(false)
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isChangesTabActive = activeTab === "changes"
const folderName = useMemo(() => {
const path = folder?.path ?? ""
@@ -502,6 +427,26 @@ export function GitChangesTab() {
return (parts[parts.length - 1] ?? path) || t("workspace")
}, [folder?.path, t])
const changes = useMemo<WorkingTreeChange[]>(() => {
return [...workspaceState.git]
.map((entry) => ({
path: entry.path,
status: entry.status,
additions: entry.additions,
deletions: entry.deletions,
}))
.sort((left, right) =>
left.path.localeCompare(right.path, undefined, { sensitivity: "base" })
)
}, [workspaceState.git])
const loading = useMemo(
() => workspaceState.health === "resyncing" && workspaceState.seq === 0,
[workspaceState.health, workspaceState.seq]
)
const error =
workspaceState.health === "degraded" ? workspaceState.error : null
const trackedChanges = useMemo(
() => changes.filter((change) => !isUntrackedStatus(change.status)),
[changes]
@@ -570,125 +515,6 @@ export function GitChangesTab() {
})
}, [allUntrackedDirectoryPaths, untrackedChanges.length])
const fetchChanges = useCallback(
async (options?: { inline?: boolean }) => {
if (!folder?.path) {
setLoading(false)
setError(null)
setChanges([])
return
}
const inline = options?.inline ?? false
if (!inline) {
setLoading(true)
}
setError(null)
try {
const statusEntries = await gitStatus(folder.path, true)
const hasTrackedEntries = statusEntries.some(
(entry) => !isUntrackedStatus(entry.status)
)
const diffText = hasTrackedEntries
? await gitDiff(folder.path).catch(() => "")
: ""
setChanges(toWorkingTreeChanges(statusEntries, diffText))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
if (!inline) {
setLoading(false)
}
}
},
[folder?.path]
)
useEffect(() => {
if (!isChangesTabActive) return
void fetchChanges()
}, [fetchChanges, isChangesTabActive])
useEffect(() => {
const rootPath = folder?.path
if (!rootPath || !isChangesTabActive) return
let unlisten: (() => void) | null = null
let disposed = false
let watchStarted = false
let watchReleased = false
const normalizedRootPath = normalizeComparePath(rootPath)
const releaseWatch = () => {
if (watchReleased) return
watchReleased = true
if (unlisten) {
unlisten()
unlisten = null
}
if (watchStarted) {
void stopFileTreeWatch(rootPath)
}
}
const scheduleRefresh = () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current)
}
refreshTimerRef.current = setTimeout(() => {
void fetchChanges({ inline: true })
}, 220)
}
const setup = async () => {
try {
await startFileTreeWatch(rootPath)
watchStarted = true
} catch {
// ignore watch startup errors
}
if (disposed) {
releaseWatch()
return
}
try {
const subscribedUnlisten = await subscribe<FileTreeChangedEvent>(
"folder://file-tree-changed",
(payload) => {
if (
normalizeComparePath(payload.root_path) !== normalizedRootPath
) {
return
}
if (!shouldRefreshFromEvent(payload)) return
scheduleRefresh()
}
)
if (disposed) {
subscribedUnlisten()
releaseWatch()
return
}
unlisten = subscribedUnlisten
} catch {
// ignore listen errors
}
}
void setup()
return () => {
disposed = true
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = null
}
releaseWatch()
}
}, [fetchChanges, folder?.path, isChangesTabActive])
const trackedCanExpand = useMemo(() => {
if (trackedTreeNodes.length === 0) return false
for (const path of allTrackedDirectoryPaths) {
@@ -719,6 +545,24 @@ export function GitChangesTab() {
[expandedUntrackedPaths.size, untrackedTreeNodes.length]
)
useEffect(() => {
logGitChangesDebug("workspace state consumed", {
rootPath: folder?.path ?? "",
seq: workspaceState.seq,
health: workspaceState.health,
gitEntries: workspaceState.git.length,
trackedChanges: trackedChanges.length,
untrackedChanges: untrackedChanges.length,
})
}, [
folder?.path,
trackedChanges.length,
untrackedChanges.length,
workspaceState.git.length,
workspaceState.health,
workspaceState.seq,
])
const toggleTrackedExpanded = useCallback(() => {
if (trackedCanExpand) {
setExpandedTrackedPaths(new Set(allTrackedDirectoryPaths))
@@ -822,13 +666,13 @@ export function GitChangesTab() {
try {
await gitAddFiles(folder.path, [target.path])
toast.success(t("toasts.addedToVcs", { name: target.name }))
await fetchChanges({ inline: true })
await workspaceState.requestResync("git_action:add")
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.addToVcsFailed"), { description: message })
}
},
[fetchChanges, folder?.path, openDirectoryGitActionDialog, t]
[folder?.path, openDirectoryGitActionDialog, t, workspaceState]
)
const handleRollbackConfirm = useCallback(async () => {
@@ -839,14 +683,14 @@ export function GitChangesTab() {
await gitRollbackFile(folder.path, rollbackTarget.path)
toast.success(t("toasts.rolledBack", { name: rollbackTarget.name }))
setRollbackTarget(null)
await fetchChanges({ inline: true })
await workspaceState.requestResync("git_action:rollback")
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.rollbackFailed"), { description: message })
} finally {
setRollingBack(false)
}
}, [fetchChanges, folder?.path, rollbackTarget, t])
}, [folder?.path, rollbackTarget, t, workspaceState])
const handleRequestDelete = useCallback(
(target: GitActionTarget, scope: "tracked" | "untracked") => {
@@ -870,14 +714,14 @@ export function GitChangesTab() {
await deleteFileTreeEntry(folder.path, deleteTarget.path)
toast.success(t("toasts.deleted", { name: deleteTarget.name }))
setDeleteTarget(null)
await fetchChanges({ inline: true })
await workspaceState.requestResync("git_action:delete")
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.deleteFailed"), { description: message })
} finally {
setDeleting(false)
}
}, [deleteTarget, fetchChanges, folder?.path, t])
}, [deleteTarget, folder?.path, t, workspaceState])
const directoryGitAllFilePaths = useMemo(
() => directoryGitCandidates.map((entry) => entry.path),
@@ -955,7 +799,7 @@ export function GitChangesTab() {
}
resetDirectoryGitActionDialog()
await fetchChanges({ inline: true })
await workspaceState.requestResync("git_action:batch")
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDirectoryGitError(message)
@@ -975,10 +819,10 @@ export function GitChangesTab() {
}, [
directoryGitActionType,
directoryGitSelectedPaths,
fetchChanges,
folder?.path,
resetDirectoryGitActionDialog,
t,
workspaceState,
])
useEffect(() => {

View File

@@ -0,0 +1,609 @@
"use client"
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"
import {
getWorkspaceSnapshot,
startWorkspaceStateStream,
stopWorkspaceStateStream,
} from "@/lib/api"
import { subscribe } from "@/lib/platform"
import type {
FileTreeNode,
WorkspaceDelta,
WorkspaceDeltaEnvelope,
WorkspaceGitEntry,
WorkspaceSnapshotResponse,
WorkspaceStateEvent,
} from "@/lib/types"
type WorkspaceHealth = "healthy" | "resyncing" | "degraded"
export interface WorkspaceStateView {
rootPath: string
seq: number
version: number
health: WorkspaceHealth
tree: FileTreeNode[]
git: WorkspaceGitEntry[]
error: string | null
}
export interface WorkspaceStateResult extends WorkspaceStateView {
requestResync: (reason?: string) => Promise<void>
}
const WORKSPACE_PROTOCOL_VERSION = 1
const STORE_EVICT_DELAY_MS = 120_000
const STORE_SHUTDOWN_GRACE_MS = 600
const WORKSPACE_DEBUG_LOG = process.env.NODE_ENV === "development"
const EMPTY_STATE: WorkspaceStateView = {
rootPath: "",
seq: 0,
version: WORKSPACE_PROTOCOL_VERSION,
health: "healthy",
tree: [],
git: [],
error: null,
}
function normalizeComparePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "")
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
function logWorkspaceDebug(message: string, payload?: Record<string, unknown>) {
if (!WORKSPACE_DEBUG_LOG) return
if (payload) {
console.info(`[WorkspaceStateStore] ${message}`, payload)
return
}
console.info(`[WorkspaceStateStore] ${message}`)
}
function summarizeSnapshot(snapshot: WorkspaceSnapshotResponse) {
return {
rootPath: snapshot.root_path,
seq: snapshot.seq,
full: snapshot.full,
deltas: snapshot.deltas.length,
treeRoots: snapshot.tree_snapshot?.length ?? 0,
gitEntries: snapshot.git_snapshot?.length ?? 0,
}
}
function summarizeEvent(event: WorkspaceStateEvent, localSeq: number) {
return {
rootPath: event.root_path,
kind: event.kind,
eventSeq: event.seq,
localSeq,
requiresResync: event.requires_resync,
payloadKinds: event.payload.map((delta) => delta.kind),
payloadCount: event.payload.length,
}
}
function applyDeltaToState(
state: WorkspaceStateView,
delta: WorkspaceDelta
): WorkspaceStateView {
switch (delta.kind) {
case "tree_replace":
return { ...state, tree: delta.nodes }
case "git_replace":
return { ...state, git: delta.entries }
case "meta":
return state
}
}
function applyDeltaEnvelope(
state: WorkspaceStateView,
envelope: WorkspaceDeltaEnvelope
): WorkspaceStateView {
let next = state
for (const delta of envelope.payload) {
next = applyDeltaToState(next, delta)
}
return {
...next,
seq: envelope.seq,
version: WORKSPACE_PROTOCOL_VERSION,
health: envelope.requires_resync ? "resyncing" : "healthy",
error: envelope.requires_resync ? "resync requested" : null,
}
}
function applySnapshot(
state: WorkspaceStateView,
snapshot: WorkspaceSnapshotResponse
): WorkspaceStateView {
if (snapshot.full) {
if (snapshot.seq < state.seq) {
return state
}
return {
rootPath: snapshot.root_path,
seq: snapshot.seq,
version: snapshot.version,
health: "healthy",
tree: snapshot.tree_snapshot ?? [],
git: snapshot.git_snapshot ?? [],
error: null,
}
}
let next = state
const ordered = [...snapshot.deltas].sort(
(left, right) => left.seq - right.seq
)
for (const envelope of ordered) {
if (envelope.seq <= next.seq) continue
if (envelope.seq !== next.seq + 1) {
throw new Error("workspace state delta gap")
}
next = applyDeltaEnvelope(next, envelope)
}
return {
...next,
seq: Math.max(next.seq, snapshot.seq),
version: snapshot.version,
health: "healthy",
error: null,
}
}
class WorkspaceStateStore {
private readonly rootPath: string
private readonly normalizedRootPath: string
private listeners = new Set<() => void>()
private state: WorkspaceStateView
private refCount = 0
private started = false
private starting: Promise<void> | null = null
private stopping: Promise<void> | null = null
private unlisten: (() => void) | null = null
private resyncInFlight: Promise<void> | null = null
private lifecycleId = 0
private evictionTimer: ReturnType<typeof setTimeout> | null = null
private shutdownTimer: ReturnType<typeof setTimeout> | null = null
private hasBaselineSnapshot = false
constructor(rootPath: string) {
this.rootPath = rootPath
this.normalizedRootPath = normalizeComparePath(rootPath)
this.state = {
...EMPTY_STATE,
rootPath,
}
}
getSnapshot = (): WorkspaceStateView => this.state
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
acquire = () => {
this.cancelPendingShutdown()
this.cancelEviction()
this.refCount += 1
logWorkspaceDebug("acquire", {
rootPath: this.rootPath,
refCount: this.refCount,
started: this.started,
})
if (this.refCount === 1) {
const canReuseLifecycle =
this.lifecycleId > 0 &&
(this.started || this.starting !== null || this.stopping !== null)
if (!canReuseLifecycle) {
this.lifecycleId += 1
}
const lifecycleId = this.lifecycleId
void this.ensureStarted(lifecycleId)
}
}
release = () => {
if (this.refCount === 0) return
this.refCount -= 1
logWorkspaceDebug("release", {
rootPath: this.rootPath,
refCount: this.refCount,
started: this.started,
})
if (this.refCount === 0) {
const lifecycleId = this.lifecycleId
this.scheduleShutdown(lifecycleId)
}
}
requestResync = async (reason?: string) => {
void reason
if (this.resyncInFlight) {
logWorkspaceDebug("requestResync skip in-flight", {
rootPath: this.rootPath,
reason: reason ?? "unknown",
})
return this.resyncInFlight
}
const run = async () => {
const startedAt = performance.now()
this.patchState((prev) => ({
...prev,
health: "resyncing",
}))
try {
const sinceSeq = this.hasBaselineSnapshot ? this.state.seq : undefined
logWorkspaceDebug("requestResync start", {
rootPath: this.rootPath,
reason: reason ?? "unknown",
sinceSeq: sinceSeq ?? null,
})
const snapshot = await getWorkspaceSnapshot(this.rootPath, sinceSeq)
this.patchState((prev) => applySnapshot(prev, snapshot))
if (snapshot.full) {
this.hasBaselineSnapshot = true
}
logWorkspaceDebug("requestResync success", {
...summarizeSnapshot(snapshot),
reason: reason ?? "unknown",
durationMs: Math.round(performance.now() - startedAt),
})
} catch (error) {
this.patchState((prev) => ({
...prev,
health: "degraded",
error: toErrorMessage(error),
}))
logWorkspaceDebug("requestResync failed", {
rootPath: this.rootPath,
reason: reason ?? "unknown",
durationMs: Math.round(performance.now() - startedAt),
error: toErrorMessage(error),
})
}
}
this.resyncInFlight = run().finally(() => {
this.resyncInFlight = null
})
return this.resyncInFlight
}
private ensureStarted = async (lifecycleId: number) => {
if (this.started) return
if (this.starting) {
await this.starting
if (!this.isLifecycleActive(lifecycleId) || this.started) {
return
}
await this.ensureStarted(lifecycleId)
return
}
const start = async () => {
if (this.stopping) {
await this.stopping
}
if (!this.isLifecycleActive(lifecycleId)) {
return
}
try {
const streamStartedAt = performance.now()
logWorkspaceDebug("ensureStarted start stream", {
rootPath: this.rootPath,
lifecycleId,
})
const initialSnapshot = await startWorkspaceStateStream(this.rootPath)
if (!this.isLifecycleActive(lifecycleId)) {
await stopWorkspaceStateStream(this.rootPath).catch(() => {})
logWorkspaceDebug("ensureStarted aborted after initial snapshot", {
rootPath: this.rootPath,
lifecycleId,
})
return
}
this.patchState((prev) => applySnapshot(prev, initialSnapshot))
this.hasBaselineSnapshot = true
logWorkspaceDebug("ensureStarted initial snapshot", {
...summarizeSnapshot(initialSnapshot),
lifecycleId,
durationMs: Math.round(performance.now() - streamStartedAt),
})
const unlisten = await subscribe<WorkspaceStateEvent>(
"folder://workspace-state-event",
(event) => {
if (
normalizeComparePath(event.root_path) !== this.normalizedRootPath
) {
return
}
this.handleEvent(event)
}
)
logWorkspaceDebug("ensureStarted subscribe ready", {
rootPath: this.rootPath,
lifecycleId,
})
if (!this.isLifecycleActive(lifecycleId)) {
unlisten()
await stopWorkspaceStateStream(this.rootPath).catch(() => {})
return
}
this.unlisten = unlisten
this.started = true
const catchUpStartedAt = performance.now()
const catchUpSnapshot = await getWorkspaceSnapshot(
this.rootPath,
this.state.seq
)
if (!this.isLifecycleActive(lifecycleId)) {
logWorkspaceDebug("ensureStarted aborted after catch-up snapshot", {
rootPath: this.rootPath,
lifecycleId,
})
return
}
this.patchState((prev) => applySnapshot(prev, catchUpSnapshot))
logWorkspaceDebug("ensureStarted catch-up snapshot", {
...summarizeSnapshot(catchUpSnapshot),
lifecycleId,
durationMs: Math.round(performance.now() - catchUpStartedAt),
})
} catch (error) {
this.patchState((prev) => ({
...prev,
health: "degraded",
error: toErrorMessage(error),
}))
logWorkspaceDebug("ensureStarted failed", {
rootPath: this.rootPath,
lifecycleId,
error: toErrorMessage(error),
})
}
}
this.starting = start().finally(() => {
this.starting = null
})
await this.starting
}
private shutdown = async (lifecycleId: number) => {
void lifecycleId
this.started = false
logWorkspaceDebug("shutdown", {
rootPath: this.rootPath,
lifecycleId,
})
const unlisten = this.unlisten
this.unlisten = null
if (unlisten) {
unlisten()
}
await stopWorkspaceStateStream(this.rootPath).catch(() => {})
}
private cancelPendingShutdown = () => {
if (!this.shutdownTimer) return
clearTimeout(this.shutdownTimer)
this.shutdownTimer = null
}
private scheduleShutdown = (lifecycleId: number) => {
this.cancelPendingShutdown()
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null
if (this.refCount !== 0) {
logWorkspaceDebug("shutdown grace canceled by new acquire", {
rootPath: this.rootPath,
lifecycleId,
refCount: this.refCount,
})
return
}
const dispose = async () => {
await this.shutdown(lifecycleId)
}
const stopping = dispose().finally(() => {
if (this.stopping === stopping) {
this.stopping = null
}
if (this.refCount === 0) {
this.scheduleEviction()
}
})
this.stopping = stopping
void stopping
}, STORE_SHUTDOWN_GRACE_MS)
}
private cancelEviction = () => {
if (!this.evictionTimer) return
clearTimeout(this.evictionTimer)
this.evictionTimer = null
}
private scheduleEviction = () => {
this.cancelEviction()
this.evictionTimer = setTimeout(() => {
this.evictionTimer = null
if (this.refCount !== 0) return
if (this.started || this.starting || this.stopping || this.unlisten)
return
deleteStore(this.normalizedRootPath, this)
}, STORE_EVICT_DELAY_MS)
}
private isLifecycleCurrent = (lifecycleId: number) => {
return this.lifecycleId === lifecycleId
}
private isLifecycleActive = (lifecycleId: number) => {
return this.isLifecycleCurrent(lifecycleId) && this.refCount > 0
}
private handleEvent = (event: WorkspaceStateEvent) => {
logWorkspaceDebug("event received", summarizeEvent(event, this.state.seq))
if (event.version !== WORKSPACE_PROTOCOL_VERSION) {
logWorkspaceDebug("event version mismatch", {
rootPath: this.rootPath,
eventVersion: event.version,
expectedVersion: WORKSPACE_PROTOCOL_VERSION,
})
void this.requestResync("version_mismatch")
return
}
if (event.requires_resync || event.seq !== this.state.seq + 1) {
logWorkspaceDebug("event requires resync", {
rootPath: this.rootPath,
kind: event.kind,
eventSeq: event.seq,
localSeq: this.state.seq,
requiresResync: event.requires_resync,
})
void this.requestResync("seq_gap_or_hint")
return
}
let next = this.state
for (const delta of event.payload) {
next = applyDeltaToState(next, delta)
}
this.patchState(() => ({
...next,
rootPath: event.root_path,
seq: event.seq,
version: event.version,
health: "healthy",
error: null,
}))
logWorkspaceDebug("event applied", {
rootPath: event.root_path,
seq: event.seq,
treeRoots: next.tree.length,
gitEntries: next.git.length,
})
}
private patchState = (
updater:
| WorkspaceStateView
| ((prev: WorkspaceStateView) => WorkspaceStateView)
) => {
this.state =
typeof updater === "function"
? (updater as (prev: WorkspaceStateView) => WorkspaceStateView)(
this.state
)
: updater
this.emit()
}
private emit = () => {
for (const listener of this.listeners) {
listener()
}
}
}
const stores = new Map<string, WorkspaceStateStore>()
function deleteStore(normalizedRootPath: string, store: WorkspaceStateStore) {
const current = stores.get(normalizedRootPath)
if (current === store) {
stores.delete(normalizedRootPath)
}
}
function getStore(rootPath: string): WorkspaceStateStore {
const normalized = normalizeComparePath(rootPath)
const existing = stores.get(normalized)
if (existing) return existing
const created = new WorkspaceStateStore(rootPath)
stores.set(normalized, created)
return created
}
export function useWorkspaceStateStore(
rootPath: string | null
): WorkspaceStateResult {
const store = useMemo(() => {
if (!rootPath) return null
return getStore(rootPath)
}, [rootPath])
useEffect(() => {
if (!store || !rootPath) return
store.acquire()
return () => {
store.release()
}
}, [rootPath, store])
const subscribeToStore = useCallback(
(onStoreChange: () => void) => {
if (!store) return () => {}
return store.subscribe(onStoreChange)
},
[store]
)
const getSnapshot = useCallback(() => {
if (!store) return EMPTY_STATE
return store.getSnapshot()
}, [store])
const snapshot = useSyncExternalStore(
subscribeToStore,
getSnapshot,
getSnapshot
)
const requestResync = useCallback(
async (reason?: string) => {
if (!store) return
await store.requestResync(reason)
},
[store]
)
if (!rootPath) {
return {
...EMPTY_STATE,
requestResync,
}
}
return {
...snapshot,
requestResync,
}
}

View File

@@ -42,6 +42,7 @@ import type {
FilePreviewContent,
FileEditContent,
FileSaveResult,
WorkspaceSnapshotResponse,
GitLogResult,
SystemLanguageSettings,
SystemProxySettings,
@@ -1267,12 +1268,26 @@ export async function getFileTree(
})
}
export async function startFileTreeWatch(rootPath: string): Promise<void> {
return getTransport().call("start_file_tree_watch", { rootPath })
export async function startWorkspaceStateStream(
rootPath: string
): Promise<WorkspaceSnapshotResponse> {
return getTransport().call("start_workspace_state_stream", { rootPath })
}
export async function stopFileTreeWatch(rootPath: string): Promise<void> {
return getTransport().call("stop_file_tree_watch", { rootPath })
export async function stopWorkspaceStateStream(
rootPath: string
): Promise<void> {
return getTransport().call("stop_workspace_state_stream", { rootPath })
}
export async function getWorkspaceSnapshot(
rootPath: string,
sinceSeq?: number
): Promise<WorkspaceSnapshotResponse> {
return getTransport().call("get_workspace_snapshot", {
rootPath,
sinceSeq: sinceSeq ?? null,
})
}
export async function readFileBase64(

View File

@@ -40,6 +40,7 @@ import type {
FilePreviewContent,
FileEditContent,
FileSaveResult,
WorkspaceSnapshotResponse,
GitLogResult,
SystemLanguageSettings,
SystemProxySettings,
@@ -1010,12 +1011,26 @@ export async function getFileTree(
return invoke("get_file_tree", { path, maxDepth: maxDepth ?? null })
}
export async function startFileTreeWatch(rootPath: string): Promise<void> {
return invoke("start_file_tree_watch", { rootPath })
export async function startWorkspaceStateStream(
rootPath: string
): Promise<WorkspaceSnapshotResponse> {
return invoke("start_workspace_state_stream", { rootPath })
}
export async function stopFileTreeWatch(rootPath: string): Promise<void> {
return invoke("stop_file_tree_watch", { rootPath })
export async function stopWorkspaceStateStream(
rootPath: string
): Promise<void> {
return invoke("stop_workspace_state_stream", { rootPath })
}
export async function getWorkspaceSnapshot(
rootPath: string,
sinceSeq?: number
): Promise<WorkspaceSnapshotResponse> {
return invoke("get_workspace_snapshot", {
rootPath,
sinceSeq: sinceSeq ?? null,
})
}
export async function readFileBase64(

View File

@@ -831,12 +831,42 @@ export interface FileSaveResult {
line_ending: "lf" | "crlf" | "mixed" | "none"
}
export interface FileTreeChangedEvent {
export interface WorkspaceGitEntry {
path: string
status: string
additions: number
deletions: number
}
export type WorkspaceDelta =
| { kind: "tree_replace"; nodes: FileTreeNode[] }
| { kind: "git_replace"; entries: WorkspaceGitEntry[] }
| { kind: "meta"; reason: string }
export interface WorkspaceDeltaEnvelope {
seq: number
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[]
requires_resync: boolean
}
export interface WorkspaceStateEvent {
root_path: string
changed_paths: string[]
kind: "create" | "modify" | "remove" | "access" | "any" | "other"
full_reload: boolean
refresh_git_status: boolean
seq: number
version: number
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[]
requires_resync: boolean
}
export interface WorkspaceSnapshotResponse {
root_path: string
seq: number
version: number
full: boolean
tree_snapshot: FileTreeNode[] | null
git_snapshot: WorkspaceGitEntry[] | null
deltas: WorkspaceDeltaEnvelope[]
}
export interface GitLogResult {