diff --git a/.changeset/fine-aliens-peel.md b/.changeset/fine-aliens-peel.md new file mode 100644 index 00000000..824880e9 --- /dev/null +++ b/.changeset/fine-aliens-peel.md @@ -0,0 +1,5 @@ +--- +"@1git/gpuix": minor +--- + +Add consistent List and Tree file navigation across Changes, History, Stashes, Graph, and comparisons, with folder inclusion and a remembered layout preference. Use a compact icon toggle beside Search and Refresh in Changes and in each saved-file pane header. Reveal truncated file and folder labels on hover or keyboard focus with the same sliding text as workspace tabs, without text tooltips. diff --git a/.github/assets/tree-view.png b/.github/assets/tree-view.png new file mode 100644 index 00000000..903d6864 Binary files /dev/null and b/.github/assets/tree-view.png differ diff --git a/apps/gpuix/src/app.tsx b/apps/gpuix/src/app.tsx index 213a446b..8f00251e 100644 --- a/apps/gpuix/src/app.tsx +++ b/apps/gpuix/src/app.tsx @@ -88,6 +88,10 @@ import { import { useWorkspaceSession } from "./feature/workspace-tabs/hooks/use-workspace-session"; import { RepositoryNavigation } from "./feature/repository-workspace/components/repository-navigation"; +import { + RepositoryFileViewProvider, + useRepositoryFileLayout, +} from "./feature/repository-workspace/components/repository-file-view"; import { RepositoryFileActions } from "./feature/repository-workspace/lib/repository-file-actions"; import { RepositoryDiffEmpty } from "./feature/repository-workspace/components/repository-diff-empty"; import { RepositoryDiff } from "./feature/repository-workspace/components/repository-diff"; @@ -213,11 +217,15 @@ export function App({ return ( - - - - - + + + + + + + ); } @@ -663,6 +671,8 @@ function AppContent({ (tab) => tab.id === onboardingId )?.workspace; + const { layout: fileListLayout } = useRepositoryFileLayout(); + const session = useWorkspaceSession( state, saveSession, @@ -678,7 +688,8 @@ function AppContent({ theme.smoothTransitions, theme.showScrollbars, theme.typography, - tips + tips, + fileListLayout ); useEffect(() => { @@ -2188,6 +2199,7 @@ function AppContent({ /> ) : activeTab?.comparison?.intent === "pull-request" && workspace ? ( ) : activeTab?.comparison ? ( { }); type Readers = { + fileTreeScope?: string; readComparison?: typeof Reader.read; readHistory?: typeof RepositoryHistory.read; readCommitFiles?: typeof RepositoryHistory.readFiles; @@ -386,6 +388,7 @@ function ComparisonResults({ previewCache, diffSettings, onDiffSettingsChange, + fileTreeScope, }: { request: ComparisonRequest; base: string; head: string } & Readers) { const theme = useAppTheme(); const { tokens: appTokens } = theme; @@ -589,12 +592,37 @@ function ComparisonResults({ }} /> - - {commit ? "Commit files" : "Changed files"} · {files.length} - +
+ + {commit ? "Commit files" : "Changed files"} · {files.length} + + +
- {`Changed files · ${files.length}`} + > + {`Changed files · ${files.length}`} + + [0], | "readComparison" + | "fileTreeScope" | "readHistory" | "readCommitFiles" | "readDiff" diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx index 7f1d1783..236115e4 100644 --- a/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx @@ -1,6 +1,7 @@ import { createThemedStyles, useAppTheme } from "../../../styles/theme"; import { spacing, typography, fontWeight, iconSize } from "@1git/design-tokens"; import { StyleSheet } from "@1git/gpuix-stylesheet"; +import { OverflowingLabel } from "../../../fixture/overflowing-label"; import type { WorkspaceEntry } from "../types"; @@ -39,18 +40,13 @@ const themedStyles = createThemedStyles(({ colors: appColors }) => { fontWeight: fontWeight.regular, color: appColors.editorInk, flexShrink: 0, - maxWidth: "100%", whiteSpace: "nowrap", - textOverflow: "ellipsis", - overflowX: "hidden", }, path: { ...typography.xs, color: appColors.editorFaint, - minWidth: 0, + flexShrink: 0, whiteSpace: "nowrap", - textOverflow: "ellipsis", - overflowX: "hidden", }, }); @@ -58,7 +54,15 @@ const themedStyles = createThemedStyles(({ colors: appColors }) => { }); /** Filename-first identity for changed files; the full path remains in the row's accessible label. */ -export function RepositoryChangeLabel({ entry }: { entry: WorkspaceEntry }) { +export function RepositoryChangeLabel({ + entry, + showDirectory = true, + reveal = false, +}: { + entry: WorkspaceEntry; + showDirectory?: boolean; + reveal?: boolean; +}) { const theme = useAppTheme(); const { tokens: appTokens, colors: appColors } = theme; const { styles } = themedStyles.get(theme); @@ -87,7 +91,13 @@ export function RepositoryChangeLabel({ entry }: { entry: WorkspaceEntry }) { : appColors.brand, }} /> -
+ ) : null} - {directory ? ( + {showDirectory && directory ? ( {directory} ) : null} -
+ {status === "U" ? ( Conflict diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-file-view.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-file-view.tsx new file mode 100644 index 00000000..c656f9c9 --- /dev/null +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-file-view.tsx @@ -0,0 +1,184 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button } from "@1git/gpuix-ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@1git/gpuix-ui/tooltip"; +import { useAppTheme } from "../../../styles/theme"; +import type { RepositoryFileLayout } from "../lib/repository-file-tree"; +import { WorkspaceTabIcon } from "../../workspace-tabs/components/workspace-tab-icon"; + +const emptyFolders: ReadonlySet = new Set(); + +const FileLayoutContext = createContext<{ + layout: RepositoryFileLayout; + setLayout?: (layout: RepositoryFileLayout) => void; +}>({ layout: "list" }); + +const FileViewContext = createContext<{ + folders: ReadonlyMap>; + revealed: Map; + setFolders?: ( + scope: string, + update: (paths: ReadonlySet) => ReadonlySet + ) => void; +}>({ folders: new Map(), revealed: new Map() }); + +/** Window-owned layout and per-workspace/pane expansion survive file-list remounts. */ +export function RepositoryFileViewProvider({ + initialLayout = "list", + children, +}: { + initialLayout?: RepositoryFileLayout; + children: ReactNode; +}) { + const [layout, setLayout] = useState(initialLayout); + const revealed = useRef(new Map()); + + const [folders, setFolders] = useState< + ReadonlyMap> + >(new Map()); + + const updateFolders = useCallback( + ( + scope: string, + update: (paths: ReadonlySet) => ReadonlySet + ) => { + setFolders((previous) => { + const current = previous.get(scope) ?? emptyFolders; + const next = update(current); + + return current === next ? previous : new Map(previous).set(scope, next); + }); + }, + [] + ); + + const value = useMemo( + () => ({ + folders, + setFolders: updateFolders, + revealed: revealed.current, + }), + [folders, updateFolders] + ); + + const layoutValue = useMemo(() => ({ layout, setLayout }), [layout]); + + return ( + + {children} + + ); +} + +/** Subscribes to the saved layout without rerendering the app for folder disclosure. */ +export function useRepositoryFileLayout() { + return useContext(FileLayoutContext); +} + +/** Shared layout preference and retained folder state for a file-pane owner. */ +export function useRepositoryFileView(scope = "") { + const context = useContext(FileViewContext); + const layout = useRepositoryFileLayout(); + const setFolders = context.setFolders; + + const toggle = (paths: readonly string[]) => + setFolders?.(scope, (current) => { + const next = new Set(current); + const expanding = paths.some((path) => current.has(path)); + + for (const path of paths) { + if (expanding) next.delete(path); + } + + const last = paths.at(-1); + + if (!expanding && last !== undefined) next.add(last); + + return next; + }); + + const reveal = useCallback( + (path: string, owner: string, force: boolean) => { + const selection = JSON.stringify([owner, path]); + + if (!force && context.revealed.get(scope) === selection) return; + + context.revealed.set(scope, selection); + setFolders?.(scope, (current) => { + const next = new Set( + [...current].filter((folder) => !path.startsWith(`${folder}/`)) + ); + + return next.size === current.size ? current : next; + }); + }, + [setFolders, scope, context.revealed] + ); + + return { + ...context, + ...layout, + collapsed: context.folders.get(scope) ?? emptyFolders, + toggle, + reveal, + }; +} + +/** One icon button toggles the shared file layout without taking extra toolbar space. */ +export function RepositoryFileLayoutToggle({ + testId = "file-layout", +}: { + testId?: string; +}) { + const { layout, setLayout } = useRepositoryFileLayout(); + const { colors, tokens } = useAppTheme(); + const tree = layout === "tree"; + const action = tree ? "Switch to list view" : "Switch to tree view"; + + return ( + + + + + + {action} + + + ); +} diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-list.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-list.tsx index e786c8ec..6c7655e2 100644 --- a/apps/gpuix/src/feature/repository-workspace/components/repository-list.tsx +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-list.tsx @@ -3,6 +3,7 @@ import { spacing, radius, typography, fontWeight } from "@1git/design-tokens"; import { StyleSheet } from "@1git/gpuix-stylesheet"; import { Scrollbars } from "@1git/gpuix-ui/scrollbar"; import { Checkbox } from "@1git/gpuix-ui/checkbox"; +import { OverflowingLabel } from "../../../fixture/overflowing-label"; import { useGpuixRequired, useWindowSize, @@ -10,7 +11,7 @@ import { type PublicInstance, } from "@gpuix/react"; import { - useEffect, + useCallback, useLayoutEffect, useMemo, useRef, @@ -23,6 +24,11 @@ import { type RepositoryListState, } from "../lib/repository-workspace-model"; import type { WorkspaceEntry } from "../types"; +import { + RepositoryFileTree, + type RepositoryFileTreeRow, +} from "../lib/repository-file-tree"; +import { useRepositoryFileView } from "./repository-file-view"; import { RepositoryChangeLabel } from "./repository-change-details"; import { RepositoryCommitSubject } from "./repository-commit-subject"; import { WorkspaceTabIcon } from "../../workspace-tabs/components/workspace-tab-icon"; @@ -50,6 +56,7 @@ const themedStyles = createThemedStyles(({ colors: appColors }) => { }, row: { display: "flex", + width: "100%", flexDirection: "row", alignItems: "center", gap: spacing[2], @@ -106,6 +113,7 @@ function RepositoryRow({ onPreviewIntent, children, itemHeight, + treeDepth, }: { entry: WorkspaceEntry; selected: boolean; @@ -122,10 +130,13 @@ function RepositoryRow({ onPreviewIntent?: (entry: WorkspaceEntry | null) => void; children?: ReactNode; itemHeight?: number; + treeDepth?: number; }) { const theme = useAppTheme(); const { tokens: appTokens, colors: appColors } = theme; const { styles } = themedStyles.get(theme); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); const description = [ entry.detail, @@ -140,9 +151,23 @@ function RepositoryRow({ const row = (onMenu?: (event: EventPayload) => void) => (
onPreviewIntent(entry) : undefined} - onMouseLeave={onPreviewIntent ? () => onPreviewIntent(null) : undefined} + onMouseEnter={() => { + if (entry.status) setHovered(true); + + onPreviewIntent?.(entry); + }} + onMouseLeave={() => { + if (entry.status) setHovered(false); + + onPreviewIntent?.(null); + }} style={StyleSheet.compose( styles.row, !!entry.status && { @@ -152,13 +177,25 @@ function RepositoryRow({ }, !!entry.detail && { height: revisionRowHeight }, itemHeight !== undefined && { height: itemHeight }, + treeDepth !== undefined && { + paddingLeft: spacing[2] + treeDepth * spacing[4], + }, selected && styles.selected )} >
{ + if (entry.status) setFocused(true); + onPreviewIntent?.(entry); onReveal(); }} onBlur={() => { + if (entry.status) setFocused(false); + onPreviewIntent?.(null); }} onKeyDown={(event) => { @@ -201,7 +242,11 @@ function RepositoryRow({ > {children ?? (entry.status ? ( - + ) : (
{ + setFocused(true); + onReveal(); + }} + onBlur={() => setFocused(false)} onKeyDown={(event) => { if (event.key !== "space" && event.key !== "enter") onKeyDown(event); @@ -297,6 +346,138 @@ function RepositoryRow({ ); } +function RepositoryFolderRow({ + node, + expanded, + tabbable, + rowRef, + onToggle, + onReveal, + onKeyDown, + included, + onInclude, + disabled, +}: { + node: Extract; + expanded: boolean; + tabbable: boolean; + rowRef: (node: PublicInstance | null) => void; + onToggle: () => void; + onReveal: () => void; + onKeyDown: (event: EventPayload) => void; + included: boolean | "indeterminate"; + onInclude?: (included: boolean) => void; + disabled?: boolean; +}) { + const theme = useAppTheme(); + const { styles } = themedStyles.get(theme); + const [focused, setFocused] = useState(false); + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + aria-level={onInclude ? node.depth + 1 : undefined} + aria-expanded={onInclude ? expanded : undefined} + style={{ + ...styles.row, + borderColor: focused ? theme.colors.brand : "transparent", + borderRadius: radius.sm, + paddingLeft: spacing[2] + node.depth * spacing[4], + paddingRight: spacing[1], + gap: spacing[1], + }} + > +
{ + setFocused(true); + onReveal(); + }} + onBlur={() => setFocused(false)} + onKeyDown={onKeyDown} + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: spacing[1.5], + flexGrow: 1, + minWidth: 0, + height: "100%", + }} + > + + + + + {node.name} + + + + {node.fileIds.length} + +
+ {onInclude ? ( +
+ { + setFocused(true); + onReveal(); + }} + onBlur={() => setFocused(false)} + onKeyDown={(event) => { + if (event.key !== "space" && event.key !== "enter") + onKeyDown(event); + }} + /> +
+ ) : null} +
+ ); +} + /** Shared file/revision list with native scrolling and roving focus; remount when its data owner changes. */ export function RepositoryList({ entries, @@ -315,6 +496,7 @@ export function RepositoryList({ onPreviewIntent, renderEntry, itemHeight, + fileTree, }: { entries: readonly WorkspaceEntry[]; label: string; @@ -329,7 +511,11 @@ export function RepositoryList({ onChange: (id: string, included: boolean) => void; }; multiselect?: boolean; - onSelect: (id: string, mode?: "toggle" | "range" | "context") => void; + onSelect: ( + id: string, + mode?: "toggle" | "range" | "context", + visibleIds?: readonly string[] + ) => void; onScroll: (y: number) => void; onEndReached?: () => void; fileActions?: FileMenuActions; @@ -338,10 +524,58 @@ export function RepositoryList({ /** Custom native row contents retain this list's selection, focus and virtualization. */ renderEntry?: (entry: WorkspaceEntry) => ReactNode; itemHeight?: number; + /** Only file panes opt in; revision lists retain their chronological ordering. */ + fileTree?: { + scope: string; + owner?: string; + allEntries?: readonly WorkspaceEntry[]; + filtering?: boolean; + onInclude?: (ids: readonly string[], included: boolean) => void; + }; }) { const theme = useAppTheme(); const { styles } = themedStyles.get(theme); + const { layout, collapsed, toggle, reveal } = useRepositoryFileView( + fileTree?.scope + ); + + const treeEnabled = !!fileTree && layout === "tree"; + const treeAvailable = !!fileTree; + const sourceEntries = fileTree?.allEntries ?? entries; + + const treeNodes = useMemo( + () => (treeEnabled ? RepositoryFileTree.build(sourceEntries) : []), + [sourceEntries, treeEnabled] + ); + + const filtering = !!fileTree?.filtering; + + const matches = useMemo( + () => (filtering ? new Set(entries.map((entry) => entry.id)) : undefined), + [entries, filtering] + ); + + const displayRows = useMemo( + () => + treeEnabled + ? RepositoryFileTree.rows(treeNodes, collapsed, matches) + : entries.map((entry) => ({ + kind: "file", + id: entry.id, + entry, + depth: 0, + parentId: null, + })), + [treeEnabled, treeNodes, collapsed, matches, entries] + ); + + const visibleFileIds = useMemo( + () => + displayRows.filter((node) => node.kind === "file").map((node) => node.id), + [displayRows] + ); + const renderer = useGpuixRequired(); const viewport = useRef(null); const container = useRef(null); @@ -351,7 +585,7 @@ export function RepositoryList({ const rowHeight = itemHeight ?? (entries[0]?.detail ? revisionRowHeight : fileRowHeight); - const windowed = entries.length > windowThreshold; + const windowed = displayRows.length > windowThreshold; const window = useWindowSize(); const windowSize = Math.max( @@ -369,12 +603,12 @@ export function RepositoryList({ ); const start = windowed - ? Math.min(windowStart, Math.max(0, entries.length - windowSize)) + ? Math.min(windowStart, Math.max(0, displayRows.length - windowSize)) : 0; const end = windowed - ? Math.min(entries.length, start + windowSize) - : entries.length; + ? Math.min(displayRows.length, start + windowSize) + : displayRows.length; const pendingFocus = useRef<{ id: string; y: number } | null>(null); const backwardTab = useRef(false); @@ -415,19 +649,52 @@ export function RepositoryList({ }, [renderer]); const includedIds = useMemo(() => new Set(inclusion?.ids), [inclusion?.ids]); + const partialIds = useMemo( + () => new Set(inclusion?.partialIds), + [inclusion?.partialIds] + ); + const selectedIds = useMemo( () => new Set(state.selectedIds), [state.selectedIds] ); - const [focusId, setFocusId] = useState(state.selectedId ?? entries[0]?.id); + const [focusId, setFocusId] = useState( + state.selectedId ?? displayRows[0]?.id + ); - const tabbableId = entries.some((entry) => entry.id === focusId) + const tabbableId = displayRows.some((entry) => entry.id === focusId) ? focusId - : (entries.find((entry) => entry.id === state.selectedId)?.id ?? - entries[0]?.id); + : (displayRows.find((entry) => entry.id === state.selectedId)?.id ?? + displayRows[0]?.id); + + const selectedPath = sourceEntries.find( + (entry) => entry.id === state.selectedId + ); + + const pathToReveal = selectedPath + ? RepositoryFileTree.path(selectedPath) + : null; - useEffect(() => { + const pendingReveal = useRef(null); + const previousLayout = useRef(treeEnabled); + const treeOwner = fileTree?.owner ?? ""; + + useLayoutEffect(() => { + if (treeEnabled && pathToReveal) + reveal(pathToReveal, treeOwner, previousLayout.current !== treeEnabled); + + if (treeAvailable) pendingReveal.current = state.selectedId; + }, [ + treeEnabled, + pathToReveal, + state.selectedId, + reveal, + treeAvailable, + treeOwner, + ]); + + useLayoutEffect(() => { if (viewport.current) renderer.scrollTo?.(viewport.current.id, 0, initialScroll.current); }, [renderer]); @@ -463,35 +730,57 @@ export function RepositoryList({ scrollCallback.current(renderer.getScrollOffset?.(node.id)?.[1] ?? 0); }, scrollSaveDelayMs); - if (anchor && last >= entries.length - 3) onEndReached?.(); + if (anchor && last >= displayRows.length - 3) onEndReached?.(); }; - const revealRow = (id: string, previousY?: number): void => { - const node = viewport.current; + const revealRow = useCallback( + (id: string, previousY?: number): void => { + const node = viewport.current; - const box = - container.current && renderer.getElementBounds?.(container.current.id); + const box = + container.current && renderer.getElementBounds?.(container.current.id); - const index = entries.findIndex((entry) => entry.id === id); + const index = displayRows.findIndex((entry) => entry.id === id); - if (!node || !box || index < 0) return; + if (!node || !box || index < 0) return; - const top = -(previousY ?? renderer.getScrollOffset?.(node.id)?.[1] ?? 0); - const height = renderer.getListScrollTop?.(node.id)?.[2] ?? box.height; - const targetTop = index * rowHeight; + const top = -(previousY ?? renderer.getScrollOffset?.(node.id)?.[1] ?? 0); + const height = renderer.getListScrollTop?.(node.id)?.[2] ?? box.height; + const targetTop = index * rowHeight; - const nextTop = - targetTop < top - ? targetTop - : Math.max(top, targetTop + rowHeight - height); + const nextTop = + targetTop < top + ? targetTop + : Math.max(top, targetTop + rowHeight - height); - const anchor = Math.floor(nextTop / rowHeight); + const anchor = Math.floor(nextTop / rowHeight); - // Override native focus's top anchor with the smallest scroll that reveals the row. - renderer.scrollToItem?.(node.id, anchor, nextTop - anchor * rowHeight); - }; + // Override native focus's top anchor with the smallest scroll that reveals the row. + renderer.scrollToItem?.(node.id, anchor, nextTop - anchor * rowHeight); + }, + [displayRows, renderer, rowHeight] + ); useLayoutEffect(() => { + if (pendingReveal.current) { + const index = displayRows.findIndex( + (node) => node.id === pendingReveal.current + ); + + if (index >= 0) { + revealRow(pendingReveal.current); + + if (windowed) setWindowStart(Math.max(0, index - windowChunkSize)); + + pendingReveal.current = null; + } + } + + if (previousLayout.current !== treeEnabled) { + previousLayout.current = treeEnabled; + setFocusId(state.selectedId ?? displayRows[0]?.id); + } + const pending = pendingFocus.current; const row = pending && rows.current.get(pending.id); @@ -500,7 +789,16 @@ export function RepositoryList({ renderer.focusElement?.(row.id); revealRow(pending.id, pending.y); } - }); + }, [ + displayRows, + end, + renderer, + revealRow, + start, + state.selectedId, + treeEnabled, + windowed, + ]); const focusRow = (id: string): void => { const row = rows.current.get(id); @@ -510,7 +808,7 @@ export function RepositoryList({ : 0; if (!row) { - const index = entries.findIndex((entry) => entry.id === id); + const index = displayRows.findIndex((entry) => entry.id === id); if (!windowed || index < 0) return; @@ -525,7 +823,9 @@ export function RepositoryList({ }; const select = (id: string, mode?: "toggle" | "range" | "context"): void => { - onSelect(id, mode); + if (displayRows.find((node) => node.id === id)?.kind === "file") + onSelect(id, mode, treeEnabled ? visibleFileIds : undefined); + setFocusId(id); focusRow(id); }; @@ -542,6 +842,54 @@ export function RepositoryList({ return; const key = event.key ?? ""; + const node = displayRows.find((row) => row.id === id); + + if (treeEnabled && node) { + const expanded = + node.kind === "folder" && + (filtering || !node.paths.some((path) => collapsed.has(path))); + + if (key === "left") { + if (node.kind === "folder" && expanded && !filtering) + toggle(node.paths); + else if (node.parentId) select(node.parentId); + + return; + } + + if (key === "right") { + if (node.kind === "folder") { + if (!expanded) toggle(node.paths); + else { + const child = displayRows[displayRows.indexOf(node) + 1]; + + if (child?.parentId === node.id) select(child.id); + } + } + + return; + } + + if (node.kind === "folder" && (key === "enter" || key === "space")) { + if ( + key === "space" && + inclusion && + fileTree?.onInclude && + !inclusion.disabled + ) + fileTree.onInclude( + node.fileIds, + RepositoryFileTree.inclusion( + node.fileIds, + includedIds, + partialIds + ) !== true + ); + else if (!filtering) toggle(node.paths); + + return; + } + } if (key === "enter" || key === "space") { if ( @@ -568,7 +916,7 @@ export function RepositoryList({ : 0; const next = RepositoryWorkspaceModel.destination( - entries, + displayRows, id, key, Math.max(1, Math.floor(height / rowHeight) - 1) @@ -582,7 +930,15 @@ export function RepositoryList({ ref={container} testId={`${testId}-viewport`} style={{ ...styles.viewport, paddingBottom: spacing[1.5] }} - role={inclusion ? "grid" : "listbox"} + role={ + treeEnabled + ? inclusion + ? "treegrid" + : "tree" + : inclusion + ? "grid" + : "listbox" + } aria-label={label} aria-multiselectable={!!inclusion || multiselect} aria-description={entries.length === 0 ? emptyMessage : undefined} @@ -600,11 +956,13 @@ export function RepositoryList({ role="presentation" {...(windowed ? { - itemCount: entries.length, + itemCount: displayRows.length, estimatedItemHeight: rowHeight, windowStart: start, } - : { estimatedItemHeight: entries.length ? rowHeight : undefined })} + : { + estimatedItemHeight: displayRows.length ? rowHeight : undefined, + })} overdraw={rowHeight * 2} onVisibleRange={saveScroll} style={styles.viewport} @@ -620,93 +978,133 @@ export function RepositoryList({ )}
) : null} - {entries.slice(start, end).map((entry) => ( -
- { - const ids = - state.selectedIds ?? - (state.selectedId ? [state.selectedId] : []); - - if (ids.includes(id)) { - const selection = new Set(ids); - - return entries.filter((item) => - selection.has(item.id) - ); - } - - select(id, "context"); - - return [entry]; - }, - } - : undefined - } - selected={ - (inclusion || multiselect) && state.selectedIds - ? selectedIds.has(entry.id) - : state.selectedId === entry.id - } - included={ - inclusion?.partialIds?.includes(entry.id) - ? "indeterminate" - : includedIds.has(entry.id) - } - inclusionDisabled={inclusion?.disabled} - onInclude={ - inclusion - ? (included) => inclusion.onChange(entry.id, included) - : undefined - } - tabbable={ - tabbableId === entry.id || - (entry.id === entries[start]?.id && - !entries - .slice(start, end) - .some((item) => item.id === tabbableId)) - } - rowRef={(node) => { - if (node) rows.current.set(entry.id, node); - else rows.current.delete(entry.id); - }} - onSelect={(event) => - select( - entry.id, - inclusion || multiselect - ? event.modifiers?.shift - ? "range" - : event.modifiers?.cmd - ? "toggle" - : undefined - : undefined - ) - } - onReveal={() => { - setFocusId(entry.id); - revealRow(entry.id); + {displayRows.slice(start, end).map((node) => { + const tabbable = + tabbableId === node.id || + (node.id === displayRows[start]?.id && + !displayRows + .slice(start, end) + .some((item) => item.id === tabbableId)); + + const rowRef = (element: PublicInstance | null): void => { + if (element) rows.current.set(node.id, element); + else rows.current.delete(node.id); + }; + + const onReveal = (): void => { + setFocusId(node.id); + revealRow(node.id); + }; + + const entry = node.kind === "file" ? node.entry : null; + + return ( +
navigate(entry.id, event)} > - {renderEntry?.(entry)} - -
- ))} + {node.kind === "folder" ? ( + collapsed.has(path)) + } + tabbable={tabbable} + rowRef={rowRef} + onReveal={onReveal} + onToggle={() => { + select(node.id); + + if (!filtering) toggle(node.paths); + }} + onKeyDown={(event) => navigate(node.id, event)} + included={RepositoryFileTree.inclusion( + node.fileIds, + includedIds, + partialIds + )} + disabled={inclusion?.disabled} + onInclude={ + inclusion && fileTree?.onInclude + ? (included) => + fileTree.onInclude?.(node.fileIds, included) + : undefined + } + /> + ) : entry ? ( + { + const ids = + state.selectedIds ?? + (state.selectedId ? [state.selectedId] : []); + + if (ids.includes(id)) { + const selection = new Set(ids); + + return entries.filter((item) => + selection.has(item.id) + ); + } + + select(id, "context"); + + return [entry]; + }, + } + : undefined + } + selected={ + (inclusion || multiselect) && state.selectedIds + ? selectedIds.has(entry.id) + : state.selectedId === entry.id + } + included={ + inclusion?.partialIds?.includes(entry.id) + ? "indeterminate" + : includedIds.has(entry.id) + } + inclusionDisabled={inclusion?.disabled} + onInclude={ + inclusion + ? (included) => inclusion.onChange(entry.id, included) + : undefined + } + tabbable={tabbable} + rowRef={rowRef} + onSelect={(event) => + select( + entry.id, + inclusion || multiselect + ? event.modifiers?.shift + ? "range" + : event.modifiers?.cmd + ? "toggle" + : undefined + : undefined + ) + } + onReveal={onReveal} + onKeyDown={(event) => navigate(entry.id, event)} + > + {renderEntry?.(entry)} + + ) : null} +
+ ); + })} + onAction({ type: "include-files", ids, included }), + } + : undefined + } onContextMenu={ view === "history" && !state.preview ? onMenu : undefined } @@ -542,15 +558,15 @@ function RepositoryNavigationContent({ } : undefined } - onSelect={(id, mode) => + onSelect={(id, mode, visibleIds) => onAction({ type: "select", view, id, mode, - visibleIds: filtering - ? entries.map((entry) => entry.id) - : undefined, + visibleIds: + visibleIds ?? + (filtering ? entries.map((entry) => entry.id) : undefined), }) } onScroll={(y) => onAction({ type: "scroll", view, y })} @@ -604,6 +620,9 @@ function RepositoryNavigationContent({ ) : null}
+ {view === "changes" ? ( + + ) : null} {state.repository || state.repositoryPath ? (
; + files: WorkspaceEntry[]; + fileIds: string[]; +} + +const compareNames = (left: string, right: string): number => + left.localeCompare(right, undefined, { numeric: true }) || + (left < right ? -1 : left > right ? 1 : 0); + +/** Pure Git-path grouping and visibility; never reads the local filesystem. */ +export class RepositoryFileTree { + static path(entry: WorkspaceEntry): string { + return entry.change?.path ?? entry.commitFile?.path ?? entry.title; + } + + static build( + entries: readonly WorkspaceEntry[] + ): readonly RepositoryFileNode[] { + const root: Directory = { + path: "", + name: "", + folders: new Map(), + files: [], + fileIds: [], + }; + + for (const entry of entries) { + const parts = RepositoryFileTree.path(entry).split("/"); + let directory = root; + + for (const part of parts.slice(0, -1)) { + let child = directory.folders.get(part); + + if (!child) { + child = { + path: directory.path ? `${directory.path}/${part}` : part, + name: part, + folders: new Map(), + files: [], + fileIds: [], + }; + directory.folders.set(part, child); + } + + child.fileIds.push(entry.id); + directory = child; + } + + directory.files.push(entry); + } + + return RepositoryFileTree.children(root); + } + + private static children(directory: Directory): readonly RepositoryFileNode[] { + const folders = [...directory.folders.values()] + .sort((left, right) => compareNames(left.name, right.name)) + .map((folder): RepositoryFileFolder => { + const names = [folder.name]; + const paths = [folder.path]; + + while (!folder.files.length && folder.folders.size === 1) { + const child = folder.folders.values().next().value; + + if (!child) break; + + folder = child; + names.push(folder.name); + paths.push(folder.path); + } + + return { + kind: "folder", + id: `folder:${folder.path}/`, + path: folder.path, + name: names.join("/"), + paths, + fileIds: folder.fileIds, + children: RepositoryFileTree.children(folder), + }; + }); + + const files = directory.files + .toSorted((left, right) => + compareNames( + RepositoryFileTree.path(left), + RepositoryFileTree.path(right) + ) + ) + .map((entry): RepositoryFileNode => ({ + kind: "file", + id: entry.id, + entry, + })); + + return [...folders, ...files]; + } + + static rows( + nodes: readonly RepositoryFileNode[], + collapsed: ReadonlySet, + matches?: ReadonlySet + ): readonly RepositoryFileTreeRow[] { + const rows: RepositoryFileTreeRow[] = []; + + const visit = ( + children: readonly RepositoryFileNode[], + depth: number, + parentId: string | null + ): void => { + for (const node of children) { + if ( + matches && + !(node.kind === "file" + ? matches.has(node.id) + : node.fileIds.some((id) => matches.has(id))) + ) + continue; + + rows.push({ ...node, depth, parentId }); + + if ( + node.kind === "folder" && + (matches || !node.paths.some((path) => collapsed.has(path))) + ) + visit(node.children, depth + 1, node.id); + } + }; + + visit(nodes, 0, null); + + return rows; + } + + static inclusion( + ids: readonly string[], + included: ReadonlySet, + partial: ReadonlySet + ): boolean | "indeterminate" { + let count = 0; + let hasPartial = false; + + for (const id of ids) { + if (included.has(id)) count++; + + if (partial.has(id)) hasPartial = true; + } + + if (!count) return false; + + return count === ids.length && !hasPartial ? true : "indeterminate"; + } +} diff --git a/apps/gpuix/src/feature/repository-workspace/lib/repository-workspace-model.ts b/apps/gpuix/src/feature/repository-workspace/lib/repository-workspace-model.ts index ffc17e88..d232aa47 100644 --- a/apps/gpuix/src/feature/repository-workspace/lib/repository-workspace-model.ts +++ b/apps/gpuix/src/feature/repository-workspace/lib/repository-workspace-model.ts @@ -1510,10 +1510,16 @@ export class RepositoryWorkspaceModel { if (action.type === "select") { if (action.view === "changes" || action.view === "history") { - const visible = action.visibleIds ? new Set(action.visibleIds) : null; + const byId = new Map( + state.entries[action.view].map((entry) => [entry.id, entry]) + ); + + const entries = action.visibleIds + ? [...new Set(action.visibleIds)].flatMap((id) => { + const entry = byId.get(id); - const entries = visible - ? state.entries[action.view].filter((entry) => visible.has(entry.id)) + return entry ? [entry] : []; + }) : state.entries[action.view]; if (!entries.some((entry) => entry.id === action.id)) return state; @@ -1632,7 +1638,7 @@ export class RepositoryWorkspaceModel { } static destination( - entries: readonly WorkspaceEntry[], + entries: readonly { readonly id: string }[], id: string, key: string, pageSize: number diff --git a/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab-icon.tsx b/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab-icon.tsx index eb5f7212..f284286c 100644 --- a/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab-icon.tsx +++ b/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab-icon.tsx @@ -4,6 +4,11 @@ import { StyleSheet } from "@1git/gpuix-stylesheet"; // Phosphor Icons 2.1.10, Light weight. See ../../../../LICENSES/Phosphor-Icons.txt. const sources = { + // App-drawn file-navigation glyphs share the existing light stroke. + list: '', + tree: '', + folder: + '', // App-drawn layers matches the Spaces icon picker. layers: '', diff --git a/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab.tsx b/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab.tsx index b4b80903..f187479f 100644 --- a/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab.tsx +++ b/apps/gpuix/src/feature/workspace-tabs/components/workspace-tab.tsx @@ -1,3 +1,4 @@ +import { OverflowingLabel } from "../../../fixture/overflowing-label"; import { MachineContext } from "../../machines/machine-context"; import { createThemedStyles, useAppTheme } from "../../../styles/theme"; import { @@ -9,20 +10,8 @@ import { import { StyleSheet } from "@1git/gpuix-stylesheet"; import { Button } from "@1git/gpuix-ui/button"; import { Text } from "@1git/gpuix-ui/text"; -import { - motion, - useGpuixRequired, - type EventPayload, - type PublicInstance, -} from "@gpuix/react"; -import { - useContext, - useLayoutEffect, - useRef, - useState, - type Ref, - type ReactNode, -} from "react"; +import { motion, type EventPayload, type PublicInstance } from "@gpuix/react"; +import { useContext, useRef, useState, type Ref, type ReactNode } from "react"; import { workspaceLayout as layout } from "../../../styles/workspace-layout"; import { MachineLocation } from "../../machines/machine-location"; @@ -81,16 +70,6 @@ export function WorkspaceTabSelection({ ); } -// Match Electron's OverflowingLabel: pause, reveal the suffix, and return on leave. -const marquee = { - hoverDelay: 0.32, - minDuration: 0.7, - maxDuration: 2.2, - pixelsPerSecond: 48, - returnDuration: 0.22, - overflowTolerance: 1, -} as const; - const themedStyles = createThemedStyles(({ colors: appColors, appearance }) => { const activeSurface = connectedTabSurface(width, appColors.editorSurface); @@ -149,22 +128,6 @@ const themedStyles = createThemedStyles(({ colors: appColors, appearance }) => { borderRadius: cornerRadius.md, cursor: "pointer", }, - label: { - display: "flex", - flexDirection: "row", - alignItems: "center", - flexShrink: 1, - minWidth: 0, - overflow: "hidden", - whiteSpace: "nowrap", - }, - labelTrack: { - position: "relative", - display: "flex", - flexDirection: "row", - alignItems: "center", - flexShrink: 0, - }, name: (active: boolean) => ({ flexShrink: 1, minWidth: 0, @@ -540,31 +503,6 @@ function WorkspaceTabLabel({ const { tokens: appTokens } = theme; const { styles } = themedStyles.get(theme); - const renderer = useGpuixRequired(); - const viewport = useRef(null); - const track = useRef(null); - const [overflow, setOverflow] = useState(0); - - useLayoutEffect(() => { - if (!reveal || !viewport.current || !track.current) return; - - const viewportBounds = renderer.getElementBounds?.(viewport.current.id); - const trackBounds = renderer.getElementBounds?.(track.current.id); - - if (!viewportBounds || !trackBounds) return; - - const difference = trackBounds.width - viewportBounds.width; - - setOverflow( - difference > marquee.overflowTolerance ? Math.ceil(difference) : 0 - ); - }, [renderer, reveal, tab.name, tab.branch]); - - const duration = Math.min( - marquee.maxDuration, - Math.max(marquee.minDuration, overflow / marquee.pixelsPerSecond) - ); - return ( <> -
- + + {tab.name} + {machineName ? ` · ${machineName}` : ""} + + {tab.branch ? ( - {tab.name} - {machineName ? ` · ${machineName}` : ""} - - {tab.branch ? ( - {` / ${tab.branch}`} - ) : null} - -
+ style={styles.branch} + >{` / ${tab.branch}`} + ) : null} + ); } diff --git a/apps/gpuix/src/feature/workspace-tabs/hooks/use-workspace-session.ts b/apps/gpuix/src/feature/workspace-tabs/hooks/use-workspace-session.ts index e1023bf5..17734dfb 100644 --- a/apps/gpuix/src/feature/workspace-tabs/hooks/use-workspace-session.ts +++ b/apps/gpuix/src/feature/workspace-tabs/hooks/use-workspace-session.ts @@ -1,4 +1,5 @@ import type { TourTips } from "../../guided-tour/guided-tour"; +import type { RepositoryFileLayout } from "../../repository-workspace/lib/repository-file-tree"; import type { AppTypography } from "../../appearance-settings/app-typography"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { @@ -31,7 +32,8 @@ export function useWorkspaceSession( smoothTransitions?: boolean, showScrollbars?: boolean, typography?: AppTypography, - tips?: TourTips + tips?: TourTips, + fileListLayout?: RepositoryFileLayout ) { const content = state.preview || !save @@ -50,7 +52,8 @@ export function useWorkspaceSession( smoothTransitions, showScrollbars, typography, - tips + tips, + fileListLayout ) ); diff --git a/apps/gpuix/src/feature/workspace-tabs/lib/workspace-session.ts b/apps/gpuix/src/feature/workspace-tabs/lib/workspace-session.ts index af1c2a9e..9dc1138a 100644 --- a/apps/gpuix/src/feature/workspace-tabs/lib/workspace-session.ts +++ b/apps/gpuix/src/feature/workspace-tabs/lib/workspace-session.ts @@ -1,4 +1,5 @@ import { GuidedTour, type TourTips } from "../../guided-tour/guided-tour"; +import type { RepositoryFileLayout } from "../../repository-workspace/lib/repository-file-tree"; import { TypographyPreferences, type AppTypography, @@ -69,6 +70,7 @@ export interface WorkspaceSessionDocument { readonly repositoryDiscovery?: RepositoryDiscoveryPreferences; readonly repositoryLibrary?: RepositoryLibraryPreferences; readonly diffSettings?: DiffSettings; + readonly fileListLayout?: RepositoryFileLayout; readonly appearance?: AppAppearancePreference; readonly commitTypeBadges?: boolean; readonly smoothTransitions?: boolean; @@ -257,6 +259,9 @@ export class WorkspaceSession { ...(typeof value.showScrollbars === "boolean" ? { showScrollbars: value.showScrollbars } : {}), + ...(value.fileListLayout === "list" || value.fileListLayout === "tree" + ? { fileListLayout: value.fileListLayout } + : {}), ...(typeof value.smoothTransitions === "boolean" ? { smoothTransitions: value.smoothTransitions } : {}), @@ -317,10 +322,12 @@ export class WorkspaceSession { smoothTransitions?: boolean, showScrollbars?: boolean, typography?: AppTypography, - tips?: TourTips + tips?: TourTips, + fileListLayout?: RepositoryFileLayout ): WorkspaceSessionDocument { return { version: 1, + ...(fileListLayout ? { fileListLayout } : {}), ...(state.spaces ? { spaces: state.spaces.map((space) => diff --git a/apps/gpuix/src/fixture/overflowing-label.tsx b/apps/gpuix/src/fixture/overflowing-label.tsx new file mode 100644 index 00000000..79045289 --- /dev/null +++ b/apps/gpuix/src/fixture/overflowing-label.tsx @@ -0,0 +1,104 @@ +import { + motion, + useGpuixRequired, + useWindowSize, + type PublicInstance, + type StyleDesc, +} from "@gpuix/react"; +import { StyleSheet } from "@1git/gpuix-stylesheet"; +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useAppTheme } from "../styles/theme"; + +const marquee = { + hoverDelay: 0.32, + minDuration: 0.7, + maxDuration: 2.2, + pixelsPerSecond: 48, + returnDuration: 0.22, + overflowTolerance: 1, +} as const; + +/** Clips a label and reveals only its overflow on hover or keyboard focus, using native motion. */ +export function OverflowingLabel({ + children, + reveal, + contentKey, + testId, + style, + gap, +}: { + children: ReactNode; + reveal: boolean; + contentKey: string; + testId: string; + style?: StyleDesc; + gap?: number; +}) { + const renderer = useGpuixRequired(); + const window = useWindowSize(); + const { typography } = useAppTheme(); + const viewport = useRef(null); + const track = useRef(null); + const [overflow, setOverflow] = useState(0); + + useLayoutEffect(() => { + if (!reveal || !viewport.current || !track.current) return; + + const viewportBounds = renderer.getElementBounds?.(viewport.current.id); + const trackBounds = renderer.getElementBounds?.(track.current.id); + + if (!viewportBounds || !trackBounds) return; + + const difference = trackBounds.width - viewportBounds.width; + + setOverflow( + difference > marquee.overflowTolerance ? Math.ceil(difference) : 0 + ); + }, [renderer, reveal, contentKey, window.width, typography]); + + const duration = Math.min( + marquee.maxDuration, + Math.max(marquee.minDuration, overflow / marquee.pixelsPerSecond) + ); + + return ( +
+ + {children} + +
+ ); +} diff --git a/apps/gpuix/tests/repository-comparison/repository-comparison.component.test.tsx b/apps/gpuix/tests/repository-comparison/repository-comparison.component.test.tsx index c2c56e04..91ebcc65 100644 --- a/apps/gpuix/tests/repository-comparison/repository-comparison.component.test.tsx +++ b/apps/gpuix/tests/repository-comparison/repository-comparison.component.test.tsx @@ -7,6 +7,8 @@ import { afterEach, beforeEach, expect, it, mock } from "bun:test"; import { createTestRoot, type TestRoot } from "@gpuix/react/testing"; import type { CommitInfo } from "@1git/git"; import { RepositoryComparison } from "../../src/feature/repository-comparison/repository-comparison"; +import { RepositoryFileViewProvider } from "../../src/feature/repository-workspace/components/repository-file-view"; +import type { RepositoryFileLayout } from "../../src/feature/repository-workspace/lib/repository-file-tree"; import { RepositoryComparison as Reader, type ComparisonRequest, @@ -105,7 +107,10 @@ const click = (id: string): void => { root.renderer.flush(); }; -const show = (reader: typeof Reader.read = readComparison): void => { +const show = ( + reader: typeof Reader.read = readComparison, + layout: RepositoryFileLayout = "list" +): void => { root.render(
{ backgroundColor: "#ffffff", }} > - + + +
); }; @@ -135,6 +142,39 @@ const choose = async (branch: string): Promise => { await settle(); }; +it("uses read-only file trees for comparisons and keeps incoming commits flat", async () => { + show( + async (_root, base, head) => ({ + ok: true, + value: { + base, + head, + mergeBase: "ancestor", + files: [ + { ...file, path: "src/a.ts" }, + { ...file, path: "src/b.ts" }, + ], + }, + }), + "tree" + ); + await choose("feature"); + expect( + root.renderer.findByTestId("comparison-files-viewport")?.customProps?.role + ).toBe("tree"); + expect( + root.renderer.findByTestId("comparison-commits-viewport")?.customProps?.role + ).toBe("listbox"); + expect(root.renderer.findByTestId("include-folder:src/")).toBeUndefined(); + click("entry-folder:src/"); + expect(root.renderer.findByTestId("entry-src/a.ts")).toBeUndefined(); + click("comparison-file-layout"); + expect(root.renderer.findByTestId("entry-src/a.ts")).toBeDefined(); + expect( + root.renderer.findByTestId("comparison-files-viewport")?.customProps?.role + ).toBe("listbox"); +}); + it("retains the sidebar and shows vector artwork while waiting for a branch", async () => { show(); await settle(); diff --git a/apps/gpuix/tests/repository-graph/view.component.test.tsx b/apps/gpuix/tests/repository-graph/view.component.test.tsx index 969398d7..1f799f8d 100644 --- a/apps/gpuix/tests/repository-graph/view.component.test.tsx +++ b/apps/gpuix/tests/repository-graph/view.component.test.tsx @@ -189,6 +189,24 @@ async function open(): Promise { await settle(); } +it("switches Graph file navigation to a read-only tree without changing commit navigation", async () => { + show(); + await open(); + click(`entry-${commits[0]!.oid}`); + await settle(); + click("graph-file-layout"); + expect(element("graph-files-viewport").customProps?.role).toBe("tree"); + expect(element("graph-commits-viewport").customProps?.role).toBe("listbox"); + expect(root.renderer.findByTestId("include-folder:src/")).toBeUndefined(); + click("entry-folder:src/"); + expect(root.renderer.findByTestId("entry-src/file-0.ts")).toBeUndefined(); + expect(element("diff-header")).toBeDefined(); + click("view-changes"); + expect(element("changes-file-layout").customProps?.["aria-label"]).toBe( + "Switch to list view" + ); +}); + for (const appearance of ["light", "dark"] as const) { it(`opens a separate ${appearance} Graph rail tab and inspects commit files without replacing History`, async () => { show(appearance); diff --git a/apps/gpuix/tests/repository-workspace/file-label.component.test.tsx b/apps/gpuix/tests/repository-workspace/file-label.component.test.tsx new file mode 100644 index 00000000..9583dcbb --- /dev/null +++ b/apps/gpuix/tests/repository-workspace/file-label.component.test.tsx @@ -0,0 +1,182 @@ +/** + * Covers: delayed overflow-only file/folder reveal, reset, keyboard focus and stationary row controls. + * Real: file list, shared tab animation, native text measurement, clipping and motion. + * Doubles: Git records and offscreen-window focus notifications. + * Requires: patched GPUIX renderer and macOS graphics. + */ +import { afterEach, beforeEach, expect, it } from "bun:test"; +import { flushSync, handleGpuixEvent } from "@gpuix/react"; +import { createTestRoot, type TestRoot } from "@gpuix/react/testing"; +import { RepositoryList } from "../../src/feature/repository-workspace/components/repository-list"; +import { RepositoryFileViewProvider } from "../../src/feature/repository-workspace/components/repository-file-view"; +import type { RepositoryFileLayout } from "../../src/feature/repository-workspace/lib/repository-file-tree"; +import type { WorkspaceEntry } from "../../src/feature/repository-workspace/types"; + +const directory = "src/a-long-directory-name-with-an-important-folder-suffix"; +const path = `${directory}/pi-timeline.integration.with-an-important-filename-suffix.test.ts`; + +const entries: readonly WorkspaceEntry[] = [ + path, + `${directory}/a.ts`, + "short.ts", +].map((title) => ({ id: title, title, status: "M" })); + +const noop = (): void => {}; +let root: TestRoot; + +beforeEach(() => { + root = createTestRoot({ width: 420, height: 300 }); + root.renderer.clockPause(); +}); +afterEach(() => root.unmount()); + +function show(layout: RepositoryFileLayout = "tree"): void { + root.render( + +
+ entry.id), onChange: noop }} + onSelect={noop} + onScroll={noop} + /> +
+
+ ); +} + +function element(testId: string) { + const node = root.renderer.findByTestId(testId); + + if (!node) throw new Error(`Missing ${testId}`); + + return node; +} + +function bounds(testId: string) { + const box = root.renderer.getElementBounds(element(testId).id); + + if (!box) throw new Error(`No bounds for ${testId}`); + + return box; +} + +function advance(milliseconds: number): void { + root.renderer.flush(); + root.renderer.clockFastForward(milliseconds); + root.renderer.flush(); + root.renderer.dispatchNativeEvents(); + root.renderer.flush(); +} + +function hover(id: string): void { + const box = bounds(id); + + root.renderer.nativeSimulateMouseMove(box.x + 10, box.y + box.height / 2); + root.renderer.flush(); +} + +function leave(): void { + root.renderer.nativeSimulateMouseMove(400, 270); + advance(250); +} + +for (const layout of ["tree", "list"] as const) { + it(`reveals truncated ${layout} file labels after the tab delay without tooltips or moving row controls`, () => { + show(layout); + const viewport = bounds(`identity-${path}`); + const start = bounds(`filename-${path}`).x; + const icon = bounds(`status-${path}`); + const checkbox = bounds(`include-${path}`); + + expect(bounds(`filename-${path}`).width).toBeGreaterThan(viewport.width); + hover(`entry-${path}`); + advance(300); + expect(bounds(`filename-${path}`).x).toBe(start); + advance(400); + expect(bounds(`filename-${path}`).x).toBeLessThan(start); + advance(2300); + const suffix = bounds( + `${layout === "tree" ? "filename" : "directory"}-${path}` + ); + + expect( + Math.abs(suffix.x + suffix.width - viewport.x - viewport.width) + ).toBeLessThanOrEqual(1); + expect(bounds(`status-${path}`)).toEqual(icon); + expect(bounds(`include-${path}`)).toEqual(checkbox); + expect( + root.renderer + .findByType("div") + .some((node) => node.customProps?.role === "tooltip") + ).toBe(false); + leave(); + expect(bounds(`filename-${path}`).x).toBe(start); + }); +} + +it("slides truncated folder names while keeping counts and checkboxes fixed", () => { + show(); + const id = `folder:${directory}/`; + const viewport = bounds(`folder-label-${id}`); + const start = bounds(`folder-name-${id}`).x; + const count = bounds(`folder-count-${id}`); + const checkbox = bounds(`include-${id}`); + + hover(`entry-${id}`); + advance(3000); + const name = bounds(`folder-name-${id}`); + + expect(name.x).toBeLessThan(start); + expect( + Math.abs(name.x + name.width - viewport.x - viewport.width) + ).toBeLessThanOrEqual(1); + expect(bounds(`folder-count-${id}`)).toEqual(count); + expect(bounds(`include-${id}`)).toEqual(checkbox); + expect( + root.renderer + .findByType("div") + .some((node) => node.customProps?.role === "tooltip") + ).toBe(false); + leave(); + expect(bounds(`folder-name-${id}`).x).toBe(start); +}); + +it("leaves fitting and briefly hovered names still, and reveals overflow with keyboard focus", () => { + show(); + const shortStart = bounds("filename-short.ts").x; + const longStart = bounds(`filename-${path}`).x; + + hover("entry-short.ts"); + advance(3000); + expect(bounds("filename-short.ts").x).toBe(shortStart); + hover(`entry-${path}`); + advance(100); + leave(); + advance(3000); + expect(bounds(`filename-${path}`).x).toBe(longStart); + const row = element(`entry-${path}`); + + flushSync(() => + handleGpuixEvent({ elementId: row.id, eventType: "focus" }, root.renderer) + ); + advance(3000); + expect(bounds(`filename-${path}`).x).toBeLessThan(longStart); + expect(row.customProps?.["aria-label"]).toContain(path); + flushSync(() => + handleGpuixEvent({ elementId: row.id, eventType: "blur" }, root.renderer) + ); + advance(250); + expect(bounds(`filename-${path}`).x).toBe(longStart); +}); diff --git a/apps/gpuix/tests/repository-workspace/file-tree.component.test.tsx b/apps/gpuix/tests/repository-workspace/file-tree.component.test.tsx new file mode 100644 index 00000000..af7e5a93 --- /dev/null +++ b/apps/gpuix/tests/repository-workspace/file-tree.component.test.tsx @@ -0,0 +1,369 @@ +/** + * Covers: native folder disclosure, keyboard navigation, inclusion, filters, scope retention and virtualization. + * Real: workspace/tree models, file rows, native GPUIX layout, focus and accessibility props. + * Doubles: Git records; no filesystem mutations. + * Requires: patched GPUIX native renderer and macOS graphics. + */ +import { afterEach, beforeEach, expect, it } from "bun:test"; +import { useReducer } from "react"; +import { createTestRoot, type TestRoot } from "@gpuix/react/testing"; +import { App } from "../../src/app"; +import { RepositoryList } from "../../src/feature/repository-workspace/components/repository-list"; +import { + RepositoryFileLayoutToggle, + RepositoryFileViewProvider, +} from "../../src/feature/repository-workspace/components/repository-file-view"; +import { + RepositoryWorkspaceModel as Model, + type RepositoryWorkspaceState, +} from "../../src/feature/repository-workspace/lib/repository-workspace-model"; +import type { WorkspaceEntry } from "../../src/feature/repository-workspace/types"; + +const files: readonly WorkspaceEntry[] = [ + "src/a.ts", + "src/ui/b.ts", + "src/ui/c.ts", + "test/a.ts", + "README.md", +].map((path) => ({ id: path, title: path, status: "M" })); + +function Pane({ + scope = "first", + query = "", + entries = files, + disabled = false, + initialSelectedId, +}: { + scope?: string; + query?: string; + entries?: readonly WorkspaceEntry[]; + disabled?: boolean; + initialSelectedId?: string; +}) { + const [state, dispatch] = useReducer( + Model.reduce, + undefined, + (): RepositoryWorkspaceState => ({ + ...Model.create(), + entries: { changes: entries, history: [], stashes: [] }, + includedFileIds: entries.map((entry) => entry.id), + partialFiles: { + "src/ui/c.ts": { expectedPatch: "patch", includedLines: ["line"] }, + }, + views: { + ...Model.create().views, + changes: { + selectedId: initialSelectedId ?? entries[0]?.id ?? null, + scrollY: 0, + }, + }, + }) + ); + + const visible = query + ? entries.filter((entry) => entry.title.includes(query)) + : entries; + + return ( +
+ + + dispatch({ type: "include-files", ids, included }), + }} + inclusion={{ + ids: state.includedFileIds, + partialIds: Object.keys(state.partialFiles ?? {}), + disabled, + onChange: (id, included) => + dispatch({ type: "include-file", id, included }), + }} + onSelect={(id, mode, visibleIds) => + dispatch({ type: "select", view: "changes", id, mode, visibleIds }) + } + onScroll={() => {}} + /> + + {state.views.changes.selectedId} + +
+ ); +} + +let root: TestRoot; + +beforeEach(() => { + root = createTestRoot({ width: 1100, height: 700 }); +}); +afterEach(() => root.unmount()); + +const element = (testId: string) => { + const node = root.renderer.findByTestId(testId); + + if (!node) throw new Error(`Missing ${testId}`); + + return node; +}; + +function click(testId: string): void { + const bounds = root.renderer.getElementBounds(element(testId).id); + + if (!bounds) throw new Error(`Missing bounds for ${testId}`); + + root.renderer.nativeSimulateClick( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); + root.renderer.flush(); +} + +function key(testId: string, value: string): void { + root.renderer.nativeSimulateKeyDown(element(testId).id, value); + root.renderer.flush(); +} + +const checked = (id: string) => element(id).customProps?.["aria-checked"]; +const selected = () => element("selected").customProps?.["aria-label"]; + +it("keeps viewed files independent of disclosure and includes collapsed descendants in one folder action", () => { + root.render( + + + + ); + + expect(element("repository-list-viewport").customProps?.role).toBe( + "treegrid" + ); + const accessibility = Object.values(root.renderer.getA11yTree().nodes ?? {}); + + expect( + accessibility.some( + (node) => + node.aria?.role === "TreeGrid" && node.aria.label === "Changed files" + ) + ).toBe(true); + expect( + accessibility.some( + (node) => + node.aria?.role === "Row" && + node.aria.level === 1 && + node.aria.expanded === true + ) + ).toBe(true); + expect(checked("include-folder:src/")).toBe("mixed"); + expect(root.renderer.findByTestId("directory-src/a.ts")).toBeUndefined(); + const checkbox = root.renderer.getElementBounds( + element("include-src/a.ts").id + ); + + const folderCheckbox = root.renderer.getElementBounds( + element("include-folder:src/").id + ); + + const viewport = root.renderer.getElementBounds( + element("repository-list-viewport").id + ); + + expect(checkbox?.x).toBe(folderCheckbox?.x); + expect((checkbox?.x ?? 0) + (checkbox?.width ?? 0)).toBeGreaterThan( + (viewport?.width ?? 0) - 20 + ); + click("entry-folder:src/"); + expect(root.renderer.findByTestId("entry-src/a.ts")).toBeUndefined(); + expect(selected()).toBe("src/a.ts"); + click("include-folder:src/"); + expect(checked("include-folder:src/")).toBe(true); + click("include-folder:src/"); + expect(checked("include-folder:src/")).toBe(false); + expect(checked("include-README.md")).toBe(true); + key("entry-folder:src/", "right"); + expect(checked("include-src/a.ts")).toBe(false); + expect(selected()).toBe("src/a.ts"); + key("file-layout", "enter"); + expect(element("repository-list-viewport").customProps?.role).toBe("grid"); + expect(element("directory-src/a.ts")).toBeDefined(); + expect(checked("include-src/ui/c.ts")).toBe(false); + click("file-layout"); + expect(checked("include-folder:src/ui/")).toBe(false); +}); + +it("navigates visible folders with arrows, toggles inclusion with Space and preserves the file preview", () => { + root.render( + + + + ); + + key("entry-folder:src/", "right"); + expect(root.renderer.getFocusedElementId()).toBe( + element("entry-folder:src/ui/").id + ); + expect(selected()).toBe("src/a.ts"); + key("entry-folder:src/ui/", "right"); + expect(selected()).toBe("src/ui/b.ts"); + key("entry-src/ui/b.ts", "left"); + expect(root.renderer.getFocusedElementId()).toBe( + element("entry-folder:src/ui/").id + ); + key("entry-folder:src/ui/", "left"); + expect(root.renderer.findByTestId("entry-src/ui/b.ts")).toBeUndefined(); + expect(selected()).toBe("src/ui/b.ts"); + key("entry-folder:src/ui/", "space"); + expect(checked("include-folder:src/ui/")).toBe(true); + key("entry-folder:src/ui/", "down"); + expect(selected()).toBe("src/a.ts"); + key("entry-src/a.ts", "end"); + expect(selected()).toBe("README.md"); +}); + +it("temporarily reveals matches, retains hidden descendant inclusion and restores independent pane expansion", () => { + const render = (scope: string, query = "") => + root.render( + + + + ); + + render("first"); + click("entry-folder:src/ui/"); + expect(root.renderer.findByTestId("entry-src/ui/b.ts")).toBeUndefined(); + render("first", "b.ts"); + expect(element("entry-src/ui/b.ts")).toBeDefined(); + expect(root.renderer.findByTestId("entry-src/ui/c.ts")).toBeUndefined(); + click("include-folder:src/ui/"); + expect(checked("include-folder:src/ui/")).toBe(true); + click("include-folder:src/ui/"); + expect(checked("include-folder:src/ui/")).toBe(false); + render("first"); + expect(root.renderer.findByTestId("entry-src/ui/b.ts")).toBeUndefined(); + render("second"); + expect(element("entry-src/ui/c.ts")).toBeDefined(); + expect(checked("include-src/ui/c.ts")).toBe(false); + render("first"); + expect(root.renderer.findByTestId("entry-src/ui/c.ts")).toBeUndefined(); + click("entry-folder:src/"); + render("second"); + expect(element("entry-src/a.ts")).toBeDefined(); + render("first"); + expect(root.renderer.findByTestId("entry-src/a.ts")).toBeUndefined(); + expect(selected()).toBe("src/a.ts"); +}); + +it("windows large trees and reveals the last file with End", () => { + const entries: WorkspaceEntry[] = Array.from( + { length: 3000 }, + (_, index) => ({ + id: `src/file-${index}.ts`, + title: `src/file-${index}.ts`, + status: "M", + }) + ); + + root.render( + + + + ); + + expect(root.renderer.findByTestId("entry-src/file-2999.ts")).toBeUndefined(); + key("entry-src/file-0.ts", "end"); + expect(element("entry-src/file-2999.ts")).toBeDefined(); + expect(root.renderer.getFocusedElementId()).toBe( + element("entry-src/file-2999.ts").id + ); + expect(selected()).toBe("src/file-2999.ts"); +}); + +it("leaves inclusion unchanged while Git writes disable the checkboxes", () => { + root.render( + + + + ); + click("include-folder:src/"); + expect(checked("include-folder:src/")).toBe("mixed"); +}); + +it("reveals a restored file beyond the initial virtual window", () => { + const entries: WorkspaceEntry[] = Array.from( + { length: 3000 }, + (_, index) => ({ + id: `src/file-${index}.ts`, + title: `src/file-${index}.ts`, + status: "M", + }) + ); + + root.render( + + + + ); + const row = root.renderer.getElementBounds( + element("entry-src/file-2999.ts").id + ); + + const viewport = root.renderer.getElementBounds( + element("repository-list-viewport").id + ); + + expect(row).not.toBeNull(); + expect(row?.y).toBeGreaterThanOrEqual(viewport?.y ?? 0); + expect((row?.y ?? 0) + (row?.height ?? 0)).toBeLessThanOrEqual( + (viewport?.y ?? 0) + (viewport?.height ?? 0) + 1 + ); +}); + +it("shares layout across Changes, History and Stashes while keeping revision lists flat and expansion scoped", () => { + root.render(); + click("changes-file-layout"); + expect(element("repository-list-viewport").customProps?.role).toBe( + "treegrid" + ); + click("view-history"); + click("entry-commit-1"); + expect(element("repository-list-viewport").customProps?.role).toBe("listbox"); + expect(element("revision-files-viewport").customProps?.role).toBe("tree"); + expect(element("revision-file-layout").customProps?.["aria-label"]).toBe( + "Switch to list view" + ); + key("entry-src/app.tsx", "end"); + click("entry-folder:src/styles/"); + expect( + root.renderer.findByTestId("entry-src/styles/tokens.ts") + ).toBeUndefined(); + click("entry-commit-2"); + click("entry-commit-1"); + expect( + root.renderer.findByTestId("entry-src/styles/tokens.ts") + ).toBeUndefined(); + click("view-stashes"); + click("entry-stash-1"); + expect(element("revision-files-viewport").customProps?.role).toBe("tree"); + expect(element("entry-src/styles/tokens.ts")).toBeDefined(); + click("revision-file-layout"); + click("view-changes"); + expect(element("repository-list-viewport").customProps?.role).toBe("grid"); + expect(element("changes-file-layout").customProps?.["aria-label"]).toBe( + "Switch to tree view" + ); +}); diff --git a/apps/gpuix/tests/repository-workspace/file-tree.unit.test.ts b/apps/gpuix/tests/repository-workspace/file-tree.unit.test.ts new file mode 100644 index 00000000..32957b75 --- /dev/null +++ b/apps/gpuix/tests/repository-workspace/file-tree.unit.test.ts @@ -0,0 +1,154 @@ +/** + * Covers: Git-path grouping, compact folders, visibility, commit inclusion and displayed-order ranges. + * Real: file-tree and workspace models. + * Doubles: Git presentation records; no filesystem reads. + * Requires: none. + */ +import { expect, it } from "bun:test"; +import { RepositoryFileTree as Tree } from "../../src/feature/repository-workspace/lib/repository-file-tree"; +import { + RepositoryWorkspaceModel as Workspace, + type RepositoryWorkspaceState, +} from "../../src/feature/repository-workspace/lib/repository-workspace-model"; +import type { WorkspaceEntry } from "../../src/feature/repository-workspace/types"; + +const entry = ( + path: string, + status: WorkspaceEntry["status"] = "M" +): WorkspaceEntry => ({ id: path, title: path, status }); + +it("compacts folder chains, sorts folders before files, and retains original records", () => { + const entries = [ + entry("README.md"), + entry("src/app.tsx"), + entry("src/ui/field.tsx"), + entry("packages/ui/src/new.tsx", "A"), + ]; + + const nodes = Tree.build(entries); + const rows = Tree.rows(nodes, new Set()); + + expect( + rows.map((row) => (row.kind === "folder" ? row.name : row.id)) + ).toEqual([ + "packages/ui/src", + "packages/ui/src/new.tsx", + "src", + "ui", + "src/ui/field.tsx", + "src/app.tsx", + "README.md", + ]); + expect(rows.find((row) => row.id === "src/ui/field.tsx")).toMatchObject({ + kind: "file", + entry: entries[2], + depth: 2, + parentId: "folder:src/ui/", + }); + expect(nodes[0]).toMatchObject({ + kind: "folder", + paths: ["packages", "packages/ui", "packages/ui/src"], + fileIds: ["packages/ui/src/new.tsx"], + }); + expect(entries.map((item) => item.id)).toEqual([ + "README.md", + "src/app.tsx", + "src/ui/field.tsx", + "packages/ui/src/new.tsx", + ]); +}); + +it("supports deleted paths, case distinctions, backslashes and file-to-folder changes", () => { + const entries = [ + entry("src", "D"), + entry("src/new.ts", "A"), + entry("Src/old.ts", "D"), + entry("literal\\name.ts"), + entry("src/file10.ts"), + entry("src/file2.ts"), + ]; + + const rows = Tree.rows(Tree.build(entries), new Set()); + const ids = rows.filter((row) => row.kind === "file").map((row) => row.id); + + expect(new Set(ids)).toEqual(new Set(entries.map((item) => item.id))); + expect(ids.indexOf("src/file2.ts")).toBeLessThan( + ids.indexOf("src/file10.ts") + ); + expect( + rows.filter((row) => row.kind === "folder").map((row) => row.path) + ).toEqual(expect.arrayContaining(["src", "Src"])); + expect(rows.find((row) => row.id === "literal\\name.ts")?.depth).toBe(0); +}); + +it("reveals filtered paths without changing saved expansion or folder inclusion scope", () => { + const entries = [ + entry("src/ui/a.ts"), + entry("src/ui/b.ts"), + entry("src/app.ts"), + ]; + + const nodes = Tree.build(entries); + const collapsed = new Set(["src"]); + + expect(Tree.rows(nodes, collapsed).map((row) => row.id)).toEqual([ + "folder:src/", + ]); + const filtered = Tree.rows(nodes, collapsed, new Set(["src/ui/b.ts"])); + + expect(filtered.map((row) => row.id)).toEqual([ + "folder:src/", + "folder:src/ui/", + "src/ui/b.ts", + ]); + expect(filtered[1]).toMatchObject({ + fileIds: ["src/ui/a.ts", "src/ui/b.ts"], + }); + expect([...collapsed]).toEqual(["src"]); + expect(Tree.rows(nodes, collapsed).length).toBe(1); +}); + +it("derives mixed folder inclusion from excluded files and partial hunks", () => { + const ids = ["a", "b"]; + + expect(Tree.inclusion(ids, new Set(), new Set())).toBe(false); + expect(Tree.inclusion(ids, new Set(ids), new Set())).toBe(true); + expect(Tree.inclusion(ids, new Set(["a"]), new Set())).toBe("indeterminate"); + expect(Tree.inclusion(ids, new Set(ids), new Set(["b"]))).toBe( + "indeterminate" + ); +}); + +it("selects ranges in displayed tree order and excludes hidden files", () => { + const entries = [ + entry("a.ts"), + entry("src/z.ts"), + entry("src/a.ts"), + entry("src/hidden.ts"), + ]; + + let state: RepositoryWorkspaceState = { + ...Workspace.create(), + entries: { changes: entries, history: [], stashes: [] }, + includedFileIds: entries.map((item) => item.id), + }; + + const visibleIds = ["src/a.ts", "src/z.ts", "a.ts"]; + + state = Workspace.reduce(state, { + type: "select", + view: "changes", + id: "src/a.ts", + visibleIds, + }); + state = Workspace.reduce(state, { + type: "select", + view: "changes", + id: "src/z.ts", + mode: "range", + visibleIds, + }); + + expect(state.views.changes.selectedIds).toEqual(["src/a.ts", "src/z.ts"]); + expect(state.includedFileIds).toEqual(entries.map((item) => item.id)); +}); diff --git a/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx b/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx index e0fdee82..4935cc04 100644 --- a/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx +++ b/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx @@ -820,6 +820,10 @@ it("supports arrow-key rail selection and entry/exit with Tab", () => { ); root.renderer.simulateKeystrokes("tab"); root.renderer.flush(); + expect(root.renderer.getFocusedElementId()).toBe( + element("revision-file-layout").id + ); + key("revision-file-layout", "tab"); notifyListFocus(); expect(root.renderer.getFocusedElementId()).toBe( element("repository-list-scrollbar-vertical").id @@ -885,6 +889,10 @@ it("navigates revision files by keyboard and scrolls each pane independently, pr element("revision-divider").id ); key("revision-divider", "tab"); + expect(root.renderer.getFocusedElementId()).toBe( + element("revision-file-layout").id + ); + key("revision-file-layout", "tab"); expect(root.renderer.getFocusedElementId()).toBe( element("entry-src/app.tsx").id ); @@ -1009,6 +1017,10 @@ it("reveals the remembered row when keyboard focus returns after scrolling it of key("rail-settings", "tab"); expect(root.renderer.getFocusedElementId()).toBe(element("rail-account").id); key("rail-account", "tab"); + expect(root.renderer.getFocusedElementId()).toBe( + element("changes-file-layout").id + ); + key("changes-file-layout", "tab"); expect(root.renderer.getFocusedElementId()).toBe( element("filter-changes").id ); diff --git a/apps/gpuix/tests/workspace-session/restore.component.test.tsx b/apps/gpuix/tests/workspace-session/restore.component.test.tsx index 84a150b1..7fd20842 100644 --- a/apps/gpuix/tests/workspace-session/restore.component.test.tsx +++ b/apps/gpuix/tests/workspace-session/restore.component.test.tsx @@ -83,6 +83,49 @@ const read: typeof RepositoryReader.read = async (path) => { }; }; +it("restores and saves the file layout across all file panes", async () => { + const save = mock<(document: WorkspaceSessionDocument) => void>(() => {}); + + root.render( + + ); + await settle(); + await settle(); + expect(node("changes-file-layout").customProps?.["aria-description"]).toBe( + "Current layout: Tree view" + ); + const toggle = root.renderer.getElementBounds(node("changes-file-layout").id); + const refresh = root.renderer.getElementBounds(node("refresh-repository").id); + const search = root.renderer.getElementBounds(node("filter-changes").id); + + expect(toggle?.width).toBe(26); + expect(toggle?.y).toBe(refresh?.y); + expect(toggle?.y).toBe(search?.y); + expect((toggle?.x ?? 0) + (toggle?.width ?? 0)).toBeLessThanOrEqual( + refresh?.x ?? 0 + ); + expect((refresh?.x ?? 0) + (refresh?.width ?? 0)).toBeLessThanOrEqual( + search?.x ?? 0 + ); + expect(node("repository-list-viewport").customProps?.role).toBe("treegrid"); + click("changes-file-layout"); + await Bun.sleep(250); + root.renderer.flush(); + expect(save.mock.calls.at(-1)?.[0].fileListLayout).toBe("list"); + click("changes-file-layout"); + await Bun.sleep(250); + root.renderer.flush(); + expect(save.mock.calls.at(-1)?.[0].fileListLayout).toBe("tree"); +}); + it("restores History, loads the saved older commit and its selected file before saving navigation", async () => { const commit = commitInfo(60).oid; const save = mock<(document: WorkspaceSessionDocument) => void>(() => {}); diff --git a/apps/gpuix/tests/workspace-session/session.unit.test.ts b/apps/gpuix/tests/workspace-session/session.unit.test.ts index 78629b07..2cb5ee79 100644 --- a/apps/gpuix/tests/workspace-session/session.unit.test.ts +++ b/apps/gpuix/tests/workspace-session/session.unit.test.ts @@ -10,6 +10,22 @@ import { WorkspaceTabsModel } from "../../src/feature/workspace-tabs/lib/workspa import { repositorySnapshot } from "../_shared/repository-snapshot"; import { commitInfo, historyPage } from "../_shared/history-page"; +it("validates the optional file layout without invalidating older sessions", () => { + const document = WorkspaceSession.capture(WorkspaceTabsModel.create()); + + for (const fileListLayout of ["list", "tree"] as const) + expect( + WorkspaceSession.parse(JSON.stringify({ ...document, fileListLayout })) + .fileListLayout + ).toBe(fileListLayout); + + for (const fileListLayout of [undefined, null, "unknown", true]) + expect( + WorkspaceSession.parse(JSON.stringify({ ...document, fileListLayout })) + .fileListLayout + ).toBeUndefined(); +}); + it("persists discovery scope and hidden suggestions without saving scan results or changing older sessions", () => { const state = WorkspaceTabsModel.create(); const preferences = { folders: ["/Volumes/Work"], hidden: ["/test/hidden"] };