From 43a4c0c874ac0220375c12a26f7b701f84529d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E6=9B=9C=E5=B3=A5=E8=A1=8C?= Date: Wed, 15 Apr 2026 01:08:59 +0800 Subject: [PATCH] add tree view (#906) --- .../sections/openchamber/GitSettings.tsx | 67 +++- .../ui/src/components/views/git/ChangeRow.tsx | 3 + .../components/views/git/ChangesSection.tsx | 356 ++++++++++++++++-- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/appearanceAutoSave.ts | 7 + packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/persistence.ts | 12 + packages/ui/src/stores/useUIStore.ts | 16 +- .../server/lib/opencode/settings-helpers.js | 6 + 9 files changed, 434 insertions(+), 35 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx index dfa7179c..8c057fb6 100644 --- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Checkbox } from '@/components/ui/checkbox'; +import { Radio } from '@/components/ui/radio'; import { updateDesktopSettings } from '@/lib/persistence'; import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; @@ -9,14 +11,21 @@ export const GitSettings: React.FC = () => { const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled); const showGitignored = useFilesViewShowGitignored(); + const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode); + const setGitChangesViewMode = useUIStore((state) => state.setGitChangesViewMode); const [isLoading, setIsLoading] = React.useState(true); + type GitSettingsPayload = { + gitmojiEnabled?: boolean; + gitChangesViewMode?: 'flat' | 'tree'; + }; + // Load current settings React.useEffect(() => { const loadSettings = async () => { try { - let data: { gitmojiEnabled?: boolean } | null = null; + let data: GitSettingsPayload | null = null; // 1. Runtime settings API (VSCode) if (!data) { @@ -30,6 +39,11 @@ export const GitSettings: React.FC = () => { gitmojiEnabled: typeof (settings as Record).gitmojiEnabled === 'boolean' ? ((settings as Record).gitmojiEnabled as boolean) : undefined, + gitChangesViewMode: + (settings as Record).gitChangesViewMode === 'flat' + || (settings as Record).gitChangesViewMode === 'tree' + ? ((settings as Record).gitChangesViewMode as 'flat' | 'tree') + : undefined, }; } } catch { @@ -53,6 +67,9 @@ export const GitSettings: React.FC = () => { if (typeof data.gitmojiEnabled === 'boolean') { setSettingsGitmojiEnabled(data.gitmojiEnabled); } + if (data.gitChangesViewMode === 'flat' || data.gitChangesViewMode === 'tree') { + setGitChangesViewMode(data.gitChangesViewMode); + } } } catch (error) { @@ -62,7 +79,7 @@ export const GitSettings: React.FC = () => { } }; loadSettings(); - }, [setSettingsGitmojiEnabled]); + }, [setGitChangesViewMode, setSettingsGitmojiEnabled]); const handleGitmojiChange = React.useCallback(async (enabled: boolean) => { setSettingsGitmojiEnabled(enabled); @@ -75,6 +92,15 @@ export const GitSettings: React.FC = () => { } }, [setSettingsGitmojiEnabled]); + const handleGitChangesViewModeChange = React.useCallback((mode: 'flat' | 'tree') => { + if (mode === gitChangesViewMode) { + return; + } + + setGitChangesViewMode(mode); + void updateDesktopSettings({ gitChangesViewMode: mode }); + }, [gitChangesViewMode, setGitChangesViewMode]); + if (isLoading) { return null; } @@ -86,6 +112,43 @@ export const GitSettings: React.FC = () => {
+
+

Changes View

+
+ {[ + { id: 'flat' as const, label: 'Flat List' }, + { id: 'tree' as const, label: 'Tree View' }, + ].map((option) => { + const selected = gitChangesViewMode === option.id; + return ( +
{ handleGitChangesViewModeChange(option.id); }} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleGitChangesViewModeChange(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + { handleGitChangesViewModeChange(option.id); }} + ariaLabel={`Git changes view mode: ${option.label}`} + /> + + {option.label} + +
+ ); + })} +
+
+
(function ChangeRow({ @@ -61,6 +62,7 @@ export const ChangeRow = React.memo(function ChangeRow({ isReverting, stats, rowPaddingClassName, + indentPx = 0, }) { const descriptor = useMemo(() => describeChange(file), [file]); const indicatorLabel = descriptor.description; @@ -105,6 +107,7 @@ export const ChangeRow = React.memo(function ChangeRow({ tabIndex={0} onClick={onViewDiff} onKeyDown={handleKeyDown} + style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined} > + + + + + {directory.name} + + {directory.files.length} +
+ ); + }, [ + diffStats, + expandedDirectories, + isRevertingAll, + isTreeView, + onRevertFile, + onToggleFile, + onViewDiff, + revertingPaths, + rowPaddingClassName, + selectedPaths, + toggleDirectoryExpanded, + toggleDirectorySelection, + ]); + const handleConfirmRevertAll = React.useCallback(async () => { if (!onRevertAll || isRevertingAll || changeEntries.length === 0) { return; @@ -184,14 +490,18 @@ export const ChangesSection: React.FC = ({ style={{ height: `${rowVirtualizer.getTotalSize()}px` }} > {virtualRows.map((row) => { - const file = changeEntries[row.index]; - if (!file) { + const item = rowItems[row.index]; + if (!item) { return null; } + const key = isTreeView + ? (item as FlattenedTreeRow).key + : `file:${(item as GitStatus['files'][number]).path}`; + return (
= ({ )} style={{ transform: `translateY(${row.start}px)` }} > - onToggleFile(file.path)} - onViewDiff={() => onViewDiff(file.path)} - onRevert={() => onRevertFile(file.path)} - isReverting={revertingPaths.has(file.path) || isRevertingAll} - rowPaddingClassName={rowPaddingClassName} - /> + {renderRow(item)}
); })} ) : (
- {changeEntries.map((file, index) => ( + {rowItems.map((item, index) => (
0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60' )} > - onToggleFile(file.path)} - onViewDiff={() => onViewDiff(file.path)} - onRevert={() => onRevertFile(file.path)} - isReverting={revertingPaths.has(file.path) || isRevertingAll} - rowPaddingClassName={rowPaddingClassName} - /> + {renderRow(item)}
))}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9f9c253c..3e5c27dc 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -557,6 +557,7 @@ export interface SettingsPayload { inputBarOffset?: number; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; diffViewMode?: 'single' | 'stacked'; + gitChangesViewMode?: 'flat' | 'tree'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; openInAppId?: string; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 1e492ba6..7250c389 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -31,6 +31,7 @@ type AppearanceSlice = { inputBarOffset: number; diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffViewMode: 'single' | 'stacked'; + gitChangesViewMode: 'flat' | 'tree'; }; let initialized = false; @@ -66,6 +67,7 @@ export const startAppearanceAutoSave = (): void => { inputBarOffset: useUIStore.getState().inputBarOffset, diffLayoutPreference: useUIStore.getState().diffLayoutPreference, diffViewMode: useUIStore.getState().diffViewMode, + gitChangesViewMode: useUIStore.getState().gitChangesViewMode, }; let pending: Partial | null = null; @@ -113,6 +115,7 @@ export const startAppearanceAutoSave = (): void => { inputBarOffset: state.inputBarOffset, diffLayoutPreference: state.diffLayoutPreference, diffViewMode: state.diffViewMode, + gitChangesViewMode: state.gitChangesViewMode, }; const diff: Partial = {}; @@ -186,6 +189,9 @@ export const startAppearanceAutoSave = (): void => { if (current.diffViewMode !== previous.diffViewMode) { diff.diffViewMode = current.diffViewMode; } + if (current.gitChangesViewMode !== previous.gitChangesViewMode) { + diff.gitChangesViewMode = current.gitChangesViewMode; + } previous = current; @@ -193,4 +199,5 @@ export const startAppearanceAutoSave = (): void => { schedule(diff); } }); + }; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index e7655a68..8c26bf85 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -137,6 +137,7 @@ export type DesktopSettings = { recentModels?: Array<{ providerID: string; modelID: string }>; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; diffViewMode?: 'single' | 'stacked'; + gitChangesViewMode?: 'flat' | 'tree'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index dc97187a..f7880fce 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -450,6 +450,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setDiffViewMode(settings.diffViewMode); } } + if (typeof settings.gitChangesViewMode === 'string' + && (settings.gitChangesViewMode === 'flat' || settings.gitChangesViewMode === 'tree')) { + if (settings.gitChangesViewMode !== store.gitChangesViewMode) { + store.setGitChangesViewMode(settings.gitChangesViewMode); + } + } if (typeof settings.directoryShowHidden === 'boolean') { setDirectoryShowHidden(settings.directoryShowHidden, { persist: false }); } @@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { ) { result.diffViewMode = candidate.diffViewMode; } + if ( + typeof candidate.gitChangesViewMode === 'string' + && (candidate.gitChangesViewMode === 'flat' || candidate.gitChangesViewMode === 'tree') + ) { + result.gitChangesViewMode = candidate.gitChangesViewMode; + } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 89b26e02..4f9a59f8 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -524,6 +524,7 @@ interface UIStore { diffFileLayout: Record; diffWrapLines: boolean; diffViewMode: 'single' | 'stacked'; + gitChangesViewMode: 'flat' | 'tree'; isTimelineDialogOpen: boolean; isImagePreviewOpen: boolean; nativeNotificationsEnabled: boolean; @@ -645,6 +646,7 @@ interface UIStore { setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; setDiffViewMode: (mode: 'single' | 'stacked') => void; + setGitChangesViewMode: (mode: 'flat' | 'tree') => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; setImagePreviewOpen: (open: boolean) => void; @@ -751,6 +753,7 @@ export const useUIStore = create()( diffFileLayout: {}, diffWrapLines: false, diffViewMode: 'stacked', + gitChangesViewMode: 'flat', isTimelineDialogOpen: false, isImagePreviewOpen: false, nativeNotificationsEnabled: false, @@ -1431,6 +1434,10 @@ export const useUIStore = create()( setDiffViewMode: (mode) => { set({ diffViewMode: mode }); }, + + setGitChangesViewMode: (mode) => { + set({ gitChangesViewMode: mode }); + }, setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); @@ -1746,7 +1753,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createJSONStorage(() => getSafeStorage()), - version: 7, + version: 8, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -1815,6 +1822,12 @@ export const useUIStore = create()( state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory); } + if (version < 8) { + if (state.gitChangesViewMode !== 'flat' && state.gitChangesViewMode !== 'tree') { + state.gitChangesViewMode = 'flat'; + } + } + return state; }, partialize: (state) => ({ @@ -1859,6 +1872,7 @@ export const useUIStore = create()( diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, diffViewMode: state.diffViewMode, + gitChangesViewMode: state.gitChangesViewMode, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 71e2d219..be2ea0ea 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -371,6 +371,12 @@ export const createSettingsHelpers = (dependencies) => { result.diffViewMode = mode; } } + if (typeof candidate.gitChangesViewMode === 'string') { + const mode = candidate.gitChangesViewMode.trim(); + if (mode === 'flat' || mode === 'tree') { + result.gitChangesViewMode = mode; + } + } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; }