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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user