From 7fecf95f64066103fc243c4d09b721ea38db540e Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Thu, 10 Sep 2026 21:13:13 +0200 Subject: [PATCH 1/7] Restore files panel sidebar-only toggle --- src/renderer/panels/EditorPanel.tsx | 21 ++++++++++++---- src/renderer/panels/ExplorerSidebar.test.tsx | 25 ++++++++++++++++++++ src/renderer/panels/ExplorerSidebar.tsx | 11 +++++---- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/renderer/panels/EditorPanel.tsx b/src/renderer/panels/EditorPanel.tsx index aa247594..c860c94f 100644 --- a/src/renderer/panels/EditorPanel.tsx +++ b/src/renderer/panels/EditorPanel.tsx @@ -7,7 +7,7 @@ import { panelSearchStore } from '../stores/panelSearchStores' import { useEffect, useRef, useCallback, useState } from 'react' import type { ReactNode } from 'react' -import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, ExternalLink, FolderOpen, Folders, Github, Search } from 'lucide-react' +import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, ExternalLink, FolderOpen, Folders, Github, PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react' import { perfCount, useRenderCount } from '../lib/perf/perfClient' import log from '../lib/logger' import * as monaco from 'monaco-editor' @@ -257,6 +257,7 @@ export default function EditorPanel({ const [markdownContent, setMarkdownContent] = useState('') const [loadError, setLoadError] = useState(null) const [fileLoading, setFileLoading] = useState(!!filePath) + const [editorCollapsed, setEditorCollapsed] = useState(false) const toolbarRef = useRef(null) const [toolbarScroll, setToolbarScroll] = useState({ left: false, right: false }) const [editorBackground, setEditorBackground] = useState(() => getActiveTheme().editor.colors?.['editor.background'] ?? 'var(--surface-1)') @@ -272,6 +273,7 @@ export default function EditorPanel({ const currentWorktree = worktreeForPanel(panel, worktrees) const explorerRoot = currentWorktree?.path ?? worktreeForPanel(panel, ws?.worktrees ?? [])?.path ?? ws?.rootPath ?? '' const explorerVisible = panel?.sidebarVisible !== false + const editorVisible = !editorCollapsed || !explorerVisible || !explorerRoot const setExplorerVisible = (visible: boolean) => useAppStore.getState().setPanelNavigation(workspaceId, panelId, panel?.sidebarView === 'search' ? 'search' : 'explorer', visible) const activePanelId = useActivePanelStore((s) => s.activePanelId) @@ -292,7 +294,7 @@ export default function EditorPanel({ toolbar.removeEventListener('scroll', update) observer.disconnect() } - }, [filePath, explorerVisible, searchVisible]) + }, [filePath, explorerVisible, searchVisible, editorVisible]) const setNavigationView = (view: 'explorer' | 'search') => { useAppStore.getState().setPanelNavigation(workspaceId, panelId, view) } @@ -834,6 +836,17 @@ export default function EditorPanel({ className="shrink-0 flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-strong text-secondary hover:bg-hover hover:text-primary disabled:opacity-40" title="Open in another app" >Open + )} } -
+
{showDiff && conflict?.kind === 'changed' && (
diff --git a/src/renderer/sidebar/FileExplorer.tsx b/src/renderer/sidebar/FileExplorer.tsx index babce04f..864e26b6 100644 --- a/src/renderer/sidebar/FileExplorer.tsx +++ b/src/renderer/sidebar/FileExplorer.tsx @@ -590,7 +590,7 @@ export const FileExplorer: React.FC = ({ rootPath, workspaceI return (
= ({ rootPath, workspaceI subtitle={scopeControl ?? folderName} actions={ <> - startRootCreate('file')} title="New File"> - - - startRootCreate('folder')} title="New Folder"> - - { setSearchVisible((v) => { @@ -658,8 +652,6 @@ export const FileExplorer: React.FC = ({ rootPath, workspaceI />
- startRootCreate('file')} title="New File"> - startRootCreate('folder')} title="New Folder">
@@ -712,7 +704,7 @@ export const FileExplorer: React.FC = ({ rootPath, workspaceI ) : (
= ({ rootPath, workspaceI )}
)} +
+
+ startRootCreate('file')} title="New File"> + startRootCreate('folder')} title="New Folder"> +
) } From c6aaa06760b1477df138e3a0b3d3996f4bdf8e4b Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Thu, 10 Sep 2026 21:33:54 +0200 Subject: [PATCH 3/7] Consolidate local canvas, repository, and appearance refinements --- src/renderer/canvas/Canvas.tsx | 1 + .../canvas/CanvasNode.groupDrag.test.tsx | 32 ++++++++++++ src/renderer/canvas/CanvasNode.tsx | 42 ++++++++++----- src/renderer/canvas/useCanvasNodeStyle.ts | 28 +++++----- src/renderer/dialogs/SkillsDialog.tsx | 24 ++++----- src/renderer/lib/themeManager.test.tsx | 42 +++++++++++++++ src/renderer/lib/themeManager.ts | 7 +++ .../panels/TerminalPanel.renderScale.test.tsx | 2 +- src/renderer/panels/TerminalPanel.tsx | 6 ++- .../repository/RepositoryOverview.tsx | 2 +- src/renderer/repository/SourceControlView.tsx | 50 +++++++++--------- .../settings/AppearanceSettings.test.tsx | 52 +++++++++++++++++++ src/renderer/settings/AppearanceSettings.tsx | 29 ++++------- src/renderer/stores/canvas/nodesSlice.ts | 22 +------- .../stores/canvasStore.lifecycle.test.ts | 21 +++----- 15 files changed, 240 insertions(+), 120 deletions(-) create mode 100644 src/renderer/lib/themeManager.test.tsx create mode 100644 src/renderer/settings/AppearanceSettings.test.tsx diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 1bcaf827..fc1e47f2 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -312,6 +312,7 @@ const Canvas: React.FC = ({ children, overlayChildren, onCreateAtPo if (!el) return const onWheel = (e: WheelEvent) => { + if ((e.target as Element).closest('[data-canvas-maximized="true"]')) return handleWheelRef.current(e as unknown as React.WheelEvent) } diff --git a/src/renderer/canvas/CanvasNode.groupDrag.test.tsx b/src/renderer/canvas/CanvasNode.groupDrag.test.tsx index 8705048e..8e6d9d0b 100644 --- a/src/renderer/canvas/CanvasNode.groupDrag.test.tsx +++ b/src/renderer/canvas/CanvasNode.groupDrag.test.tsx @@ -335,3 +335,35 @@ describe('CanvasNode — group drag from the title bar', () => { expect(store.getState().nodes['B'].origin).toEqual({ x: 400, y: 0 }) }) }) + +it('uses split-sized diagonal maximize controls and restores the live canvas panel', () => { + const wsId = useAppStore.getState().addWorkspace('WS', '/tmp/ws', 'ws-maximize') + useAppStore.getState().addPanel(wsId, { id: 'panel-A', type: 'editor', title: 'Editor', isDirty: false }) + const store = freshCanvasStore() + addNode(store, 'A', 'panel-A', { x: 120, y: 230 }, { width: 400, height: 300 }) + const dock = tabsDockStore('panel-A') + act(() => root.render( + + } /> + , + )) + const node = container.querySelector('[data-node-id="A"]')! + const input = node.querySelector('input') + const button = container.querySelector('[aria-label="Maximize"]')! + expect(button.classList.contains('w-6')).toBe(true) + expect(button.querySelector('.lucide-maximize2')).not.toBeNull() + expect(button.querySelector('svg')?.getAttribute('width')).toBe('14') + act(() => button.click()) + expect(node.style.position).toBe('fixed') + expect(node.style.width).toBe('100vw') + expect(node.style.height).toBe('100vh') + expect(container.querySelector('[data-resize-frame-for]')).toBeNull() + const restore = container.querySelector('[aria-label="Restore"]')! + expect(restore.querySelector('.lucide-minimize2')).not.toBeNull() + act(() => restore.click()) + expect(node.style.position).toBe('absolute') + expect(node.style.left).toBe('120px') + expect(node.style.top).toBe('230px') + expect(node.style.width).toBe('400px') + expect(node.querySelector('input')).toBe(input) +}) diff --git a/src/renderer/canvas/CanvasNode.tsx b/src/renderer/canvas/CanvasNode.tsx index 252d8f86..cb30c18d 100644 --- a/src/renderer/canvas/CanvasNode.tsx +++ b/src/renderer/canvas/CanvasNode.tsx @@ -33,11 +33,14 @@ import { Tooltip } from '../ui/Tooltip' import DockLayoutRenderer from '../docking/DockLayoutRenderer' import { confirmClosePanels } from '../lib/confirmClosePanels' import { collectPanelIds } from '../../shared/collectPanelIds' -import { Maximize as ArrowsOutSimple, Minimize as ArrowsInSimple, X, Lock, LockOpen } from 'lucide-react' +import { Maximize2, Minimize2, X, Lock, LockOpen } from 'lucide-react' import { PANEL_DEFINITIONS } from '../../shared/panels' import { captureRendererException } from '../lib/sentry' import { useCanvasTopOverlayTarget } from './CanvasTopOverlayContext' import { worktreeForPanel } from '../lib/worktreeContext' +import { IS_MAC } from '../lib/platform' +import { useWindowFullscreen } from '../lib/useWindowFullscreen' +import { TRAFFIC_LIGHTS_WIDTH } from '../shells/MacWindowChrome' // Node ids already reported for missing geometry, so a bad node that keeps // re-rendering warns/reports once instead of spamming. @@ -98,6 +101,23 @@ const PULSE_KEYFRAMES = ` opacity: 0 !important; pointer-events: none !important; } +/* Keep live panel contents mounted while escaping canvas scaling and clipping. */ +body :has([data-canvas-maximized="true"]) { + transform: none !important; + filter: none !important; + perspective: none !important; + contain: none !important; + will-change: auto !important; + overflow: visible !important; + z-index: auto !important; +} +[data-canvas-maximized="true"] { --zoom: 1; } +[data-canvas-maximized="true"] .dock-tab-bar { + padding-left: var(--maximized-chrome-inset) !important; +} +body:has([data-canvas-maximized="true"]) [data-node-id]:not([data-canvas-maximized="true"]), +body:has([data-canvas-maximized="true"]) [data-canvas-top-overlay], +body:has([data-canvas-maximized="true"]) [data-resize-frame-for] { visibility: hidden; } ` let keyframesInjected = false @@ -132,7 +152,7 @@ function GrabButton({ data-grab-button aria-label={title} onClick={onClick} - className="flex items-center justify-center w-[18px] h-[18px] rounded text-secondary hover:text-primary hover:bg-hover" + className="flex items-center justify-center w-6 h-6 self-center rounded-[10px] text-muted hover:text-primary hover:bg-hover" style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: baseColor }} > {children} @@ -141,7 +161,7 @@ function GrabButton({ ) } -const TAB_ICON_SIZE = 12 +const TAB_ICON_SIZE = 14 // ----------------------------------------------------------------------------- // Component @@ -159,10 +179,10 @@ const CanvasNode: React.FC = ({ useRenderCount('CanvasNode') const canvasApi = useCanvasStoreApi() + const windowFullscreen = useWindowFullscreen() const topOverlayTarget = useCanvasTopOverlayTarget() const nodeRef = useRef(null) const [isHovered, setIsHovered] = useState(false) - const [isAnimatingLayout, setIsAnimatingLayout] = useState(false) // True while a file/panel drag is hovering an unfocused node, so the dim // overlay lets the drop fall through to the panel content that owns it. const [fileDragOver, setFileDragOver] = useState(false) @@ -357,10 +377,8 @@ const CanvasNode: React.FC = ({ }, [removeNode, nodeId, layout, confirmCloseForPanels, wsId]) const handleToggleMaximize = useCallback(() => { - setIsAnimatingLayout(true) const viewportSize = { width: window.innerWidth, height: window.innerHeight } toggleMaximize(nodeId, viewportSize) - setTimeout(() => setIsAnimatingLayout(false), 300) }, [toggleMaximize, nodeId]) // Spring-load: when ANY dock drag is active AND this node is maximized @@ -452,8 +470,8 @@ const CanvasNode: React.FC = ({ onClick={(e) => { e.stopPropagation(); handleToggleMaximize() }} > {maximized - ? - : } + ? + : } = ({ onPanelRemoved={handlePanelRemoved} excludePanelTypes={CANVAS_EXCLUDED_TYPES} localOnly - compact + compact={false} onTabBarMouseDown={isHeaderHost ? handleHeaderMouseDown : undefined} trailingControls={isHeaderHost ? nodeControlButtons : undefined} dropDisabled={isWholeNodeDragSource} @@ -638,7 +656,6 @@ const CanvasNode: React.FC = ({ isFocused, isSelected, activityState, - isAnimatingLayout, isHovered, chromeTint, isWholeNodeDragSource, @@ -672,8 +689,9 @@ const CanvasNode: React.FC = ({
setIsHovered(true)} @@ -823,7 +841,7 @@ const CanvasNode: React.FC = ({ Mounted as a sibling (not inside the node's overflow:hidden box) so the strips can overhang the edge; positioned to the node's bounds and stacked with it. */} - {!isWholeNodeDragSource && ( + {!maximized && !isWholeNodeDragSource && (
(() => { - if (!node) return null + if (!node || isMaximized(node)) return null if (!(isFocused || isSelected || worktreeHighlight)) return null // Hide the focus glow while the node is the drag source — the source node // itself is hidden (containerStyle.opacity = 0 above) and the glow would @@ -130,9 +134,7 @@ export function useCanvasNodeStyle(args: StyleArgs) { if (isWholeNodeDragSource) return null const isEntering = node.animationState === 'entering' const isExiting = node.animationState === 'exiting' - const layoutTransition = isAnimatingLayout - ? 'left 250ms cubic-bezier(0.16, 1, 0.3, 1), top 250ms cubic-bezier(0.16, 1, 0.3, 1), width 250ms cubic-bezier(0.16, 1, 0.3, 1), height 250ms cubic-bezier(0.16, 1, 0.3, 1), ' - : '' + return { position: 'absolute', left: node.origin.x, @@ -157,9 +159,9 @@ export function useCanvasNodeStyle(args: StyleArgs) { pointerEvents: 'none', transform: isEntering ? 'scale(0.85)' : isExiting ? 'scale(0.9)' : 'scale(1)', opacity: isEntering || isExiting ? 0 : 1, - transition: `${layoutTransition}transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 150ms ease-out, box-shadow 200ms ease`, + transition: `transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 150ms ease-out, box-shadow 200ms ease`, } - }, [node, isFocused, isSelected, isAnimatingLayout, isWholeNodeDragSource, worktreeHighlight, worktreeColor]) + }, [node, isFocused, isSelected, isWholeNodeDragSource, worktreeHighlight, worktreeColor]) return { containerStyle, glowStyle } } diff --git a/src/renderer/dialogs/SkillsDialog.tsx b/src/renderer/dialogs/SkillsDialog.tsx index db0f9b9a..76fdf1f0 100644 --- a/src/renderer/dialogs/SkillsDialog.tsx +++ b/src/renderer/dialogs/SkillsDialog.tsx @@ -44,7 +44,7 @@ import { const api = () => window.electronAPI -// The list of repos the curated catalog is crawled from. Linked at the bottom so +// The list of repos the curated catalog is crawled from. Linked in the header so // anyone can PR a missing skill's source repo in (the CI crawler turns this into // skills-index.json). const SKILL_SOURCES_URL = 'https://github.com/0-AI-UG/cate/blob/main/registry/sources.json' @@ -263,6 +263,15 @@ export function SkillsDialog() { {!contentSlot && } +
@@ -384,19 +393,6 @@ export function SkillsDialog() {
{visibleBrowseRows.map((e) => renderRow(e, false))}
)}
- - {/* Pinned footer — PR a missing skill into the curated index. Stays put - below the (possibly very long) scrolling list so it's always seen. */} -
- Missing a skill?{' '} - -
, contentSlot ?? document.body, diff --git a/src/renderer/lib/themeManager.test.tsx b/src/renderer/lib/themeManager.test.tsx new file mode 100644 index 00000000..c4b0631f --- /dev/null +++ b/src/renderer/lib/themeManager.test.tsx @@ -0,0 +1,42 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { applyTheme, getActiveTheme, subscribeTheme } from './themeManager' +import { useSettingsStore } from '../stores/settingsStore' +import { DEFAULT_SETTINGS } from '../../shared/types' +import { BUILT_IN_BY_ID, DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '../../shared/themes' +import { mergeThemeApp } from '../../shared/themeResolution' + +const custom = { + ...BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID], id: 'custom', + app: { 'surface-1': '#ff00ff', 'text-primary': '#abcdef', 'custom-token': '#123456' }, +} + +afterEach(() => { + useSettingsStore.setState(DEFAULT_SETTINGS) + applyTheme(DEFAULT_DARK_THEME_ID) + document.documentElement.style.removeProperty('--unrelated') +}) + +it.each([DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, 'deleted-theme'])('fully replaces custom colors when switching to %s', (selection) => { + useSettingsStore.setState({ customThemes: [custom] }) + applyTheme(custom.id) + expect(document.documentElement.style.getPropertyValue('--surface-1')).toBe('#ff00ff') + expect(document.documentElement.style.getPropertyValue('--custom-token')).toBe('#123456') + document.documentElement.style.setProperty('--unrelated', '42px') + useSettingsStore.setState({ customThemes: [] }) + const listener = vi.fn() + const unsubscribe = subscribeTheme(listener) + try { + applyTheme(selection) + const expected = BUILT_IN_BY_ID[selection] ?? BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID] + expect(getActiveTheme()).toBe(expected) + for (const [key, value] of Object.entries(mergeThemeApp(expected))) { + expect(document.documentElement.style.getPropertyValue('--' + key)).toBe(value) + } + expect(document.documentElement.style.getPropertyValue('--custom-token')).toBe('') + expect(document.documentElement.style.getPropertyValue('--unrelated')).toBe('42px') + expect(document.documentElement.dataset.theme).toBe(expected.type) + expect(listener).toHaveBeenCalledWith(expected) + } finally { + unsubscribe() + } +}) diff --git a/src/renderer/lib/themeManager.ts b/src/renderer/lib/themeManager.ts index 11ecdd64..2886ea44 100644 --- a/src/renderer/lib/themeManager.ts +++ b/src/renderer/lib/themeManager.ts @@ -26,6 +26,7 @@ import { mergeThemeApp, resolveTheme as resolveThemeSelection } from '../../shar let currentTheme: Theme = BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID] let currentSelection: ThemeSelection = 'system' +let appliedAppKeys = new Set() const subscribers = new Set<(t: Theme) => void>() let mediaQuery: MediaQueryList | null = null @@ -66,6 +67,12 @@ function injectAppVars(theme: Theme): void { if (typeof document === 'undefined') return const root = document.documentElement const merged = mergeThemeApp(theme) + // Settings-file themes can contain extra tokens absent from the base palette. + // Remove their old overrides when switching or deleting a theme. + for (const key of appliedAppKeys) { + if (!(key in merged)) root.style.removeProperty('--' + key) + } + appliedAppKeys = new Set(Object.keys(merged)) for (const [key, value] of Object.entries(merged)) { root.style.setProperty('--' + key, value) } diff --git a/src/renderer/panels/TerminalPanel.renderScale.test.tsx b/src/renderer/panels/TerminalPanel.renderScale.test.tsx index a8a3c7e4..238fbf58 100644 --- a/src/renderer/panels/TerminalPanel.renderScale.test.tsx +++ b/src/renderer/panels/TerminalPanel.renderScale.test.tsx @@ -130,7 +130,7 @@ vi.mock('../lib/terminal/terminalRegistry', () => { // --- collaborators the panel pulls in but that are irrelevant here ---------- -const canvasState = { zoomLevel: 2.0 } +const canvasState = { zoomLevel: 2.0, nodes: {} } vi.mock('../stores/CanvasStoreContext', () => ({ useOptionalCanvasStoreApi: () => null, // Selectors that read state this fake doesn't model fall back, as they do diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index 7cee4517..562c6bfd 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { TerminalPanelProps } from './types' +import { isMaximized } from '../../shared/types' import { terminalRegistry } from '../lib/terminal/terminalRegistry' import { formatTerminalPaste, type DroppedRef } from './terminalDrop' import { useAppStore } from '../stores/appStore' @@ -207,7 +208,10 @@ export default function TerminalPanel({ const isFocused = useOptionalCanvasStoreContext((s) => focusedNodeId(s) === nodeId, false) const canvasApi = useOptionalCanvasStoreApi() - const zoomLevel = useOptionalCanvasStoreContext((s) => s.zoomLevel, 1) + const zoomLevel = useOptionalCanvasStoreContext((s) => { + const node = nodeId ? s.nodes[nodeId] : undefined + return node && isMaximized(node) ? 1 : s.zoomLevel + }, 1) // ------------------------------------------------------------------------- // Search handlers diff --git a/src/renderer/repository/RepositoryOverview.tsx b/src/renderer/repository/RepositoryOverview.tsx index 7b96970b..6dab3d2a 100644 --- a/src/renderer/repository/RepositoryOverview.tsx +++ b/src/renderer/repository/RepositoryOverview.tsx @@ -53,7 +53,7 @@ export default function RepositoryOverview() { useUIStore.getState().openSettings('source control')}> -
+
{repositories.length > 1 ? :

{root ? pathDisplayName(root) : 'Your repositories'}

} diff --git a/src/renderer/repository/SourceControlView.tsx b/src/renderer/repository/SourceControlView.tsx index 8317dc9d..29e5ea30 100644 --- a/src/renderer/repository/SourceControlView.tsx +++ b/src/renderer/repository/SourceControlView.tsx @@ -117,21 +117,21 @@ const Section: React.FC<{ if (count === 0) return null return ( -
+
setOpen(!open)} > {open ? : } {title} - {count} + {count} {actions && (
e.stopPropagation()}> {actions}
)}
- {open &&
{children}
} + {open &&
{children}
}
) } @@ -151,15 +151,15 @@ const FileEntry: React.FC<{ const dir = dirName(file.path) return (
{statusChar} - + {fileName(file.path)} - {dir && {dir}} + {dir && {dir}}
{onDiscard && ( @@ -283,15 +283,15 @@ const BranchPicker: React.FC<{ const branchCount = branches.length || 1 // at least show current return ( -
+
{/* Section header — matches Section component style */}
setIsOpen(!isOpen)} > {isOpen ? : } Branches - {branchCount} + {branchCount} {!isOpen && ( {currentBranch} )} @@ -300,7 +300,7 @@ const BranchPicker: React.FC<{ {isOpen && (
{/* Search / Create */} -
+
{creating ? (
handleCheckout(b.name)} > @@ -379,11 +379,11 @@ const BranchPicker: React.FC<{ })} {filtered(remoteBranches).length > 0 && ( <> -
Remote
+
Remote
{filtered(remoteBranches).map(b => (
handleCheckout(b.name)} > @@ -670,8 +670,8 @@ const RepoSourceControl: React.FC = ({ rootPath, workspa return (
-
)} -
+
{/* Commit area */} {section === 'changes' && ( -
-

Commit staged changes

+
+

Commit staged changes

{stagedFiles.length} staged {stagedFiles.length === 1 ? 'file' : 'files'} · {status?.current ?? 'Detached HEAD'}