add tree view (#906)
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Radio } from '@/components/ui/radio';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
|
|
||||||
@@ -9,14 +11,21 @@ export const GitSettings: React.FC = () => {
|
|||||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||||
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
|
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
|
||||||
const showGitignored = useFilesViewShowGitignored();
|
const showGitignored = useFilesViewShowGitignored();
|
||||||
|
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
|
||||||
|
const setGitChangesViewMode = useUIStore((state) => state.setGitChangesViewMode);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
type GitSettingsPayload = {
|
||||||
|
gitmojiEnabled?: boolean;
|
||||||
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
|
};
|
||||||
|
|
||||||
// Load current settings
|
// Load current settings
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
let data: { gitmojiEnabled?: boolean } | null = null;
|
let data: GitSettingsPayload | null = null;
|
||||||
|
|
||||||
// 1. Runtime settings API (VSCode)
|
// 1. Runtime settings API (VSCode)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -30,6 +39,11 @@ export const GitSettings: React.FC = () => {
|
|||||||
gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean'
|
gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean'
|
||||||
? ((settings as Record<string, unknown>).gitmojiEnabled as boolean)
|
? ((settings as Record<string, unknown>).gitmojiEnabled as boolean)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
gitChangesViewMode:
|
||||||
|
(settings as Record<string, unknown>).gitChangesViewMode === 'flat'
|
||||||
|
|| (settings as Record<string, unknown>).gitChangesViewMode === 'tree'
|
||||||
|
? ((settings as Record<string, unknown>).gitChangesViewMode as 'flat' | 'tree')
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -53,6 +67,9 @@ export const GitSettings: React.FC = () => {
|
|||||||
if (typeof data.gitmojiEnabled === 'boolean') {
|
if (typeof data.gitmojiEnabled === 'boolean') {
|
||||||
setSettingsGitmojiEnabled(data.gitmojiEnabled);
|
setSettingsGitmojiEnabled(data.gitmojiEnabled);
|
||||||
}
|
}
|
||||||
|
if (data.gitChangesViewMode === 'flat' || data.gitChangesViewMode === 'tree') {
|
||||||
|
setGitChangesViewMode(data.gitChangesViewMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -62,7 +79,7 @@ export const GitSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, [setSettingsGitmojiEnabled]);
|
}, [setGitChangesViewMode, setSettingsGitmojiEnabled]);
|
||||||
|
|
||||||
const handleGitmojiChange = React.useCallback(async (enabled: boolean) => {
|
const handleGitmojiChange = React.useCallback(async (enabled: boolean) => {
|
||||||
setSettingsGitmojiEnabled(enabled);
|
setSettingsGitmojiEnabled(enabled);
|
||||||
@@ -75,6 +92,15 @@ export const GitSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [setSettingsGitmojiEnabled]);
|
}, [setSettingsGitmojiEnabled]);
|
||||||
|
|
||||||
|
const handleGitChangesViewModeChange = React.useCallback((mode: 'flat' | 'tree') => {
|
||||||
|
if (mode === gitChangesViewMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGitChangesViewMode(mode);
|
||||||
|
void updateDesktopSettings({ gitChangesViewMode: mode });
|
||||||
|
}, [gitChangesViewMode, setGitChangesViewMode]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -86,6 +112,43 @@ export const GitSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||||
|
<div className="pt-1 pb-1">
|
||||||
|
<h4 className="typography-ui-header font-medium text-foreground">Changes View</h4>
|
||||||
|
<div role="radiogroup" aria-label="Git changes view mode" className="mt-0.5 space-y-0">
|
||||||
|
{[
|
||||||
|
{ id: 'flat' as const, label: 'Flat List' },
|
||||||
|
{ id: 'tree' as const, label: 'Tree View' },
|
||||||
|
].map((option) => {
|
||||||
|
const selected = gitChangesViewMode === option.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => { handleGitChangesViewModeChange(option.id); }}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleGitChangesViewModeChange(option.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 py-0 text-left"
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => { handleGitChangesViewModeChange(option.id); }}
|
||||||
|
ariaLabel={`Git changes view mode: ${option.label}`}
|
||||||
|
/>
|
||||||
|
<span className={selected ? 'typography-ui-label font-normal text-foreground' : 'typography-ui-label font-normal text-foreground/50'}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ChangeRowProps {
|
|||||||
isReverting: boolean;
|
isReverting: boolean;
|
||||||
stats?: { insertions: number; deletions: number };
|
stats?: { insertions: number; deletions: number };
|
||||||
rowPaddingClassName?: string;
|
rowPaddingClassName?: string;
|
||||||
|
indentPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||||
@@ -61,6 +62,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
isReverting,
|
isReverting,
|
||||||
stats,
|
stats,
|
||||||
rowPaddingClassName,
|
rowPaddingClassName,
|
||||||
|
indentPx = 0,
|
||||||
}) {
|
}) {
|
||||||
const descriptor = useMemo(() => describeChange(file), [file]);
|
const descriptor = useMemo(() => describeChange(file), [file]);
|
||||||
const indicatorLabel = descriptor.description;
|
const indicatorLabel = descriptor.description;
|
||||||
@@ -105,6 +107,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onViewDiff}
|
onClick={onViewDiff}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
|
import { RiArrowDownSLine, RiArrowRightSLine, RiCheckboxBlankLine, RiCheckboxLine, RiSubtractLine } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,6 +15,7 @@ import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
|||||||
import { ChangeRow } from './ChangeRow';
|
import { ChangeRow } from './ChangeRow';
|
||||||
import type { GitStatus } from '@/lib/api/types';
|
import type { GitStatus } from '@/lib/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
interface ChangesSectionProps {
|
interface ChangesSectionProps {
|
||||||
changeEntries: GitStatus['files'];
|
changeEntries: GitStatus['files'];
|
||||||
@@ -35,6 +36,137 @@ interface ChangesSectionProps {
|
|||||||
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 120;
|
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 120;
|
||||||
const CHANGE_ROW_ESTIMATE_PX = 34;
|
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||||
|
|
||||||
|
type ChangesTreeDirectoryNode = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
children: Map<string, ChangesTreeDirectoryNode>;
|
||||||
|
directFiles: GitStatus['files'];
|
||||||
|
files: GitStatus['files'];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlattenedTreeRow =
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
kind: 'directory';
|
||||||
|
depth: number;
|
||||||
|
directory: ChangesTreeDirectoryNode;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
kind: 'file';
|
||||||
|
depth: number;
|
||||||
|
file: GitStatus['files'][number];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TREE_INDENT_PX = 14;
|
||||||
|
|
||||||
|
const normalizePathForTree = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||||
|
|
||||||
|
const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
|
||||||
|
id: `dir:${path}`,
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
children: new Map(),
|
||||||
|
directFiles: [],
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
|
||||||
|
const root = createDirectoryNode('', '');
|
||||||
|
|
||||||
|
for (const file of entries) {
|
||||||
|
const normalized = normalizePathForTree(file.path);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = normalized.split('/').filter(Boolean);
|
||||||
|
const directorySegments = segments.slice(0, -1);
|
||||||
|
let current = root;
|
||||||
|
current.files.push(file);
|
||||||
|
|
||||||
|
if (directorySegments.length > 0) {
|
||||||
|
let currentPath = '';
|
||||||
|
for (const segment of directorySegments) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||||
|
const existing = current.children.get(segment);
|
||||||
|
if (existing) {
|
||||||
|
existing.files.push(file);
|
||||||
|
current = existing;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = createDirectoryNode(currentPath, segment);
|
||||||
|
created.files.push(file);
|
||||||
|
current.children.set(segment, created);
|
||||||
|
current = created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current.directFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenChangesTree = (
|
||||||
|
root: ChangesTreeDirectoryNode,
|
||||||
|
expandedDirectories: Set<string>,
|
||||||
|
): FlattenedTreeRow[] => {
|
||||||
|
const rows: FlattenedTreeRow[] = [];
|
||||||
|
|
||||||
|
const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
|
||||||
|
const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
for (const directory of directories) {
|
||||||
|
rows.push({
|
||||||
|
key: directory.id,
|
||||||
|
kind: 'directory',
|
||||||
|
depth,
|
||||||
|
directory,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expandedDirectories.has(directory.path)) {
|
||||||
|
walk(directory, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
for (const file of directFiles) {
|
||||||
|
rows.push({
|
||||||
|
key: `file:${normalizePathForTree(file.path)}`,
|
||||||
|
kind: 'file',
|
||||||
|
depth,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root, 0);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirectorySelectionState = (
|
||||||
|
directory: ChangesTreeDirectoryNode,
|
||||||
|
selectedPaths: Set<string>
|
||||||
|
): 'none' | 'partial' | 'all' => {
|
||||||
|
if (directory.files.length === 0) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedCount = 0;
|
||||||
|
for (const file of directory.files) {
|
||||||
|
if (selectedPaths.has(file.path)) {
|
||||||
|
selectedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCount === 0) return 'none';
|
||||||
|
if (selectedCount === directory.files.length) return 'all';
|
||||||
|
return 'partial';
|
||||||
|
};
|
||||||
|
|
||||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||||
changeEntries,
|
changeEntries,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
@@ -51,16 +183,53 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
onVisiblePathsChange,
|
onVisiblePathsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
|
||||||
|
const isTreeView = gitChangesViewMode === 'tree';
|
||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const totalCount = changeEntries.length;
|
const totalCount = changeEntries.length;
|
||||||
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
|
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
|
||||||
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
const treeRoot = React.useMemo(() => buildChangesTree(changeEntries), [changeEntries]);
|
||||||
|
const [expandedDirectories, setExpandedDirectories] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const topLevelDirectoryPaths = React.useMemo(
|
||||||
|
() => Array.from(treeRoot.children.values()).map((directory) => directory.path),
|
||||||
|
[treeRoot]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isTreeView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedDirectories((previous) => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
const validTopLevel = new Set(topLevelDirectoryPaths);
|
||||||
|
|
||||||
|
previous.forEach((path) => {
|
||||||
|
if (path.includes('/')) {
|
||||||
|
next.add(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (validTopLevel.has(path)) {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
topLevelDirectoryPaths.forEach((path) => next.add(path));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [isTreeView, topLevelDirectoryPaths]);
|
||||||
|
|
||||||
|
const treeRows = React.useMemo(() => flattenChangesTree(treeRoot, expandedDirectories), [expandedDirectories, treeRoot]);
|
||||||
|
const rowItems = React.useMemo(() => (isTreeView ? treeRows : changeEntries), [changeEntries, isTreeView, treeRows]);
|
||||||
|
const rowCount = rowItems.length;
|
||||||
|
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||||
const hasAnySelected = selectedCount > 0;
|
const hasAnySelected = selectedCount > 0;
|
||||||
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
|
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
|
||||||
const isPartiallySelected = hasAnySelected && !areAllSelected;
|
const isPartiallySelected = hasAnySelected && !areAllSelected;
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: totalCount,
|
count: rowCount,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -104,24 +273,161 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalCount === 0) {
|
if (rowCount === 0) {
|
||||||
onVisiblePathsChange([]);
|
onVisiblePathsChange([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toVisiblePath = (item: GitStatus['files'][number] | FlattenedTreeRow): string | null => {
|
||||||
|
if (!isTreeView) {
|
||||||
|
return (item as GitStatus['files'][number]).path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeItem = item as FlattenedTreeRow;
|
||||||
|
return treeItem.kind === 'file' ? treeItem.file.path : null;
|
||||||
|
};
|
||||||
|
|
||||||
if (!shouldVirtualize) {
|
if (!shouldVirtualize) {
|
||||||
onVisiblePathsChange(changeEntries.slice(0, Math.min(30, totalCount)).map((entry) => entry.path));
|
onVisiblePathsChange(
|
||||||
|
rowItems
|
||||||
|
.slice(0, Math.min(30, rowCount))
|
||||||
|
.map((item) => toVisiblePath(item))
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
|
onVisiblePathsChange(
|
||||||
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
|
virtualRows
|
||||||
|
.map((row) => rowItems[row.index])
|
||||||
|
.map((item) => (item ? toVisiblePath(item) : null))
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
);
|
||||||
|
}, [isTreeView, onVisiblePathsChange, rowCount, rowItems, shouldVirtualize, virtualRows]);
|
||||||
|
|
||||||
const containerClassName = 'flex flex-col flex-1 min-h-0';
|
const containerClassName = 'flex flex-col flex-1 min-h-0';
|
||||||
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
||||||
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||||
const rowPaddingClassName = 'pl-0 pr-2';
|
const rowPaddingClassName = 'pl-0 pr-2';
|
||||||
|
|
||||||
|
const toggleDirectoryExpanded = React.useCallback((path: string) => {
|
||||||
|
setExpandedDirectories((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDirectorySelection = React.useCallback((directory: ChangesTreeDirectoryNode) => {
|
||||||
|
const state = getDirectorySelectionState(directory, selectedPaths);
|
||||||
|
const shouldSelectAll = state !== 'all';
|
||||||
|
|
||||||
|
for (const file of directory.files) {
|
||||||
|
const isSelected = selectedPaths.has(file.path);
|
||||||
|
if (shouldSelectAll && !isSelected) {
|
||||||
|
onToggleFile(file.path);
|
||||||
|
} else if (!shouldSelectAll && isSelected) {
|
||||||
|
onToggleFile(file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onToggleFile, selectedPaths]);
|
||||||
|
|
||||||
|
const renderRow = React.useCallback((item: GitStatus['files'][number] | FlattenedTreeRow) => {
|
||||||
|
if (!isTreeView) {
|
||||||
|
const file = item as GitStatus['files'][number];
|
||||||
|
return (
|
||||||
|
<ChangeRow
|
||||||
|
file={file}
|
||||||
|
checked={selectedPaths.has(file.path)}
|
||||||
|
stats={diffStats?.[file.path]}
|
||||||
|
onToggle={() => onToggleFile(file.path)}
|
||||||
|
onViewDiff={() => onViewDiff(file.path)}
|
||||||
|
onRevert={() => onRevertFile(file.path)}
|
||||||
|
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||||
|
rowPaddingClassName={rowPaddingClassName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = item as FlattenedTreeRow;
|
||||||
|
|
||||||
|
if (row.kind === 'file') {
|
||||||
|
const file = row.file;
|
||||||
|
return (
|
||||||
|
<ChangeRow
|
||||||
|
file={file}
|
||||||
|
checked={selectedPaths.has(file.path)}
|
||||||
|
stats={diffStats?.[file.path]}
|
||||||
|
onToggle={() => onToggleFile(file.path)}
|
||||||
|
onViewDiff={() => onViewDiff(file.path)}
|
||||||
|
onRevert={() => onRevertFile(file.path)}
|
||||||
|
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||||
|
rowPaddingClassName={rowPaddingClassName}
|
||||||
|
indentPx={row.depth * TREE_INDENT_PX}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = row.directory;
|
||||||
|
const isExpanded = expandedDirectories.has(directory.path);
|
||||||
|
const selectionState = getDirectorySelectionState(directory, selectedPaths);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', rowPaddingClassName)}
|
||||||
|
style={{ paddingLeft: `${row.depth * TREE_INDENT_PX}px` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDirectoryExpanded(directory.path)}
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
aria-label={isExpanded ? `Collapse ${directory.path}` : `Expand ${directory.path}`}
|
||||||
|
>
|
||||||
|
{isExpanded ? <RiArrowDownSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectionState === 'partial' ? 'mixed' : selectionState === 'all'}
|
||||||
|
aria-label={`Toggle selection for directory ${directory.path}`}
|
||||||
|
onClick={() => toggleDirectorySelection(directory)}
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
{selectionState === 'all' ? (
|
||||||
|
<RiCheckboxLine className="size-4 text-primary" />
|
||||||
|
) : selectionState === 'partial' ? (
|
||||||
|
<RiSubtractLine className="size-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="min-w-0 truncate typography-ui-label text-foreground" title={directory.path}>
|
||||||
|
{directory.name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
diffStats,
|
||||||
|
expandedDirectories,
|
||||||
|
isRevertingAll,
|
||||||
|
isTreeView,
|
||||||
|
onRevertFile,
|
||||||
|
onToggleFile,
|
||||||
|
onViewDiff,
|
||||||
|
revertingPaths,
|
||||||
|
rowPaddingClassName,
|
||||||
|
selectedPaths,
|
||||||
|
toggleDirectoryExpanded,
|
||||||
|
toggleDirectorySelection,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||||
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -184,14 +490,18 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
>
|
>
|
||||||
{virtualRows.map((row) => {
|
{virtualRows.map((row) => {
|
||||||
const file = changeEntries[row.index];
|
const item = rowItems[row.index];
|
||||||
if (!file) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = isTreeView
|
||||||
|
? (item as FlattenedTreeRow).key
|
||||||
|
: `file:${(item as GitStatus['files'][number]).path}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={key}
|
||||||
ref={rowVirtualizer.measureElement}
|
ref={rowVirtualizer.measureElement}
|
||||||
data-index={row.index}
|
data-index={row.index}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -200,40 +510,22 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
style={{ transform: `translateY(${row.start}px)` }}
|
style={{ transform: `translateY(${row.start}px)` }}
|
||||||
>
|
>
|
||||||
<ChangeRow
|
{renderRow(item)}
|
||||||
file={file}
|
|
||||||
checked={selectedPaths.has(file.path)}
|
|
||||||
stats={diffStats?.[file.path]}
|
|
||||||
onToggle={() => onToggleFile(file.path)}
|
|
||||||
onViewDiff={() => onViewDiff(file.path)}
|
|
||||||
onRevert={() => onRevertFile(file.path)}
|
|
||||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
|
||||||
rowPaddingClassName={rowPaddingClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div role="list" aria-label="Changed files">
|
<div role="list" aria-label="Changed files">
|
||||||
{changeEntries.map((file, index) => (
|
{rowItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={isTreeView ? (item as FlattenedTreeRow).key : `file:${(item as GitStatus['files'][number]).path}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative',
|
'relative',
|
||||||
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChangeRow
|
{renderRow(item)}
|
||||||
file={file}
|
|
||||||
checked={selectedPaths.has(file.path)}
|
|
||||||
stats={diffStats?.[file.path]}
|
|
||||||
onToggle={() => onToggleFile(file.path)}
|
|
||||||
onViewDiff={() => onViewDiff(file.path)}
|
|
||||||
onRevert={() => onRevertFile(file.path)}
|
|
||||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
|
||||||
rowPaddingClassName={rowPaddingClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ export interface SettingsPayload {
|
|||||||
inputBarOffset?: number;
|
inputBarOffset?: number;
|
||||||
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
diffViewMode?: 'single' | 'stacked';
|
diffViewMode?: 'single' | 'stacked';
|
||||||
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
directoryShowHidden?: boolean;
|
directoryShowHidden?: boolean;
|
||||||
filesViewShowGitignored?: boolean;
|
filesViewShowGitignored?: boolean;
|
||||||
openInAppId?: string;
|
openInAppId?: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type AppearanceSlice = {
|
|||||||
inputBarOffset: number;
|
inputBarOffset: number;
|
||||||
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
diffViewMode: 'single' | 'stacked';
|
diffViewMode: 'single' | 'stacked';
|
||||||
|
gitChangesViewMode: 'flat' | 'tree';
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
@@ -66,6 +67,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
inputBarOffset: useUIStore.getState().inputBarOffset,
|
inputBarOffset: useUIStore.getState().inputBarOffset,
|
||||||
diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
|
diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
|
||||||
diffViewMode: useUIStore.getState().diffViewMode,
|
diffViewMode: useUIStore.getState().diffViewMode,
|
||||||
|
gitChangesViewMode: useUIStore.getState().gitChangesViewMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pending: Partial<DesktopSettings> | null = null;
|
let pending: Partial<DesktopSettings> | null = null;
|
||||||
@@ -113,6 +115,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
inputBarOffset: state.inputBarOffset,
|
inputBarOffset: state.inputBarOffset,
|
||||||
diffLayoutPreference: state.diffLayoutPreference,
|
diffLayoutPreference: state.diffLayoutPreference,
|
||||||
diffViewMode: state.diffViewMode,
|
diffViewMode: state.diffViewMode,
|
||||||
|
gitChangesViewMode: state.gitChangesViewMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const diff: Partial<DesktopSettings> = {};
|
const diff: Partial<DesktopSettings> = {};
|
||||||
@@ -186,6 +189,9 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
if (current.diffViewMode !== previous.diffViewMode) {
|
if (current.diffViewMode !== previous.diffViewMode) {
|
||||||
diff.diffViewMode = current.diffViewMode;
|
diff.diffViewMode = current.diffViewMode;
|
||||||
}
|
}
|
||||||
|
if (current.gitChangesViewMode !== previous.gitChangesViewMode) {
|
||||||
|
diff.gitChangesViewMode = current.gitChangesViewMode;
|
||||||
|
}
|
||||||
|
|
||||||
previous = current;
|
previous = current;
|
||||||
|
|
||||||
@@ -193,4 +199,5 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
schedule(diff);
|
schedule(diff);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export type DesktopSettings = {
|
|||||||
recentModels?: Array<{ providerID: string; modelID: string }>;
|
recentModels?: Array<{ providerID: string; modelID: string }>;
|
||||||
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
diffViewMode?: 'single' | 'stacked';
|
diffViewMode?: 'single' | 'stacked';
|
||||||
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
directoryShowHidden?: boolean;
|
directoryShowHidden?: boolean;
|
||||||
filesViewShowGitignored?: boolean;
|
filesViewShowGitignored?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
store.setDiffViewMode(settings.diffViewMode);
|
store.setDiffViewMode(settings.diffViewMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof settings.gitChangesViewMode === 'string'
|
||||||
|
&& (settings.gitChangesViewMode === 'flat' || settings.gitChangesViewMode === 'tree')) {
|
||||||
|
if (settings.gitChangesViewMode !== store.gitChangesViewMode) {
|
||||||
|
store.setGitChangesViewMode(settings.gitChangesViewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (typeof settings.directoryShowHidden === 'boolean') {
|
if (typeof settings.directoryShowHidden === 'boolean') {
|
||||||
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
|
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
|
||||||
}
|
}
|
||||||
@@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
) {
|
) {
|
||||||
result.diffViewMode = candidate.diffViewMode;
|
result.diffViewMode = candidate.diffViewMode;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
typeof candidate.gitChangesViewMode === 'string'
|
||||||
|
&& (candidate.gitChangesViewMode === 'flat' || candidate.gitChangesViewMode === 'tree')
|
||||||
|
) {
|
||||||
|
result.gitChangesViewMode = candidate.gitChangesViewMode;
|
||||||
|
}
|
||||||
if (typeof candidate.directoryShowHidden === 'boolean') {
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
||||||
result.directoryShowHidden = candidate.directoryShowHidden;
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ interface UIStore {
|
|||||||
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
|
||||||
diffWrapLines: boolean;
|
diffWrapLines: boolean;
|
||||||
diffViewMode: 'single' | 'stacked';
|
diffViewMode: 'single' | 'stacked';
|
||||||
|
gitChangesViewMode: 'flat' | 'tree';
|
||||||
isTimelineDialogOpen: boolean;
|
isTimelineDialogOpen: boolean;
|
||||||
isImagePreviewOpen: boolean;
|
isImagePreviewOpen: boolean;
|
||||||
nativeNotificationsEnabled: boolean;
|
nativeNotificationsEnabled: boolean;
|
||||||
@@ -645,6 +646,7 @@ interface UIStore {
|
|||||||
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
|
||||||
setDiffWrapLines: (wrap: boolean) => void;
|
setDiffWrapLines: (wrap: boolean) => void;
|
||||||
setDiffViewMode: (mode: 'single' | 'stacked') => void;
|
setDiffViewMode: (mode: 'single' | 'stacked') => void;
|
||||||
|
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
|
||||||
setMultiRunLauncherOpen: (open: boolean) => void;
|
setMultiRunLauncherOpen: (open: boolean) => void;
|
||||||
setTimelineDialogOpen: (open: boolean) => void;
|
setTimelineDialogOpen: (open: boolean) => void;
|
||||||
setImagePreviewOpen: (open: boolean) => void;
|
setImagePreviewOpen: (open: boolean) => void;
|
||||||
@@ -751,6 +753,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffFileLayout: {},
|
diffFileLayout: {},
|
||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
diffViewMode: 'stacked',
|
diffViewMode: 'stacked',
|
||||||
|
gitChangesViewMode: 'flat',
|
||||||
isTimelineDialogOpen: false,
|
isTimelineDialogOpen: false,
|
||||||
isImagePreviewOpen: false,
|
isImagePreviewOpen: false,
|
||||||
nativeNotificationsEnabled: false,
|
nativeNotificationsEnabled: false,
|
||||||
@@ -1431,6 +1434,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setDiffViewMode: (mode) => {
|
setDiffViewMode: (mode) => {
|
||||||
set({ diffViewMode: mode });
|
set({ diffViewMode: mode });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setGitChangesViewMode: (mode) => {
|
||||||
|
set({ gitChangesViewMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
setInputBarOffset: (offset) => {
|
setInputBarOffset: (offset) => {
|
||||||
set({ inputBarOffset: offset });
|
set({ inputBarOffset: offset });
|
||||||
@@ -1746,7 +1753,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
storage: createJSONStorage(() => getSafeStorage()),
|
storage: createJSONStorage(() => getSafeStorage()),
|
||||||
version: 7,
|
version: 8,
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState;
|
return persistedState;
|
||||||
@@ -1815,6 +1822,12 @@ export const useUIStore = create<UIStore>()(
|
|||||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 8) {
|
||||||
|
if (state.gitChangesViewMode !== 'flat' && state.gitChangesViewMode !== 'tree') {
|
||||||
|
state.gitChangesViewMode = 'flat';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
@@ -1859,6 +1872,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffLayoutPreference: state.diffLayoutPreference,
|
diffLayoutPreference: state.diffLayoutPreference,
|
||||||
diffWrapLines: state.diffWrapLines,
|
diffWrapLines: state.diffWrapLines,
|
||||||
diffViewMode: state.diffViewMode,
|
diffViewMode: state.diffViewMode,
|
||||||
|
gitChangesViewMode: state.gitChangesViewMode,
|
||||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||||
notificationMode: state.notificationMode,
|
notificationMode: state.notificationMode,
|
||||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||||
|
|||||||
@@ -371,6 +371,12 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
result.diffViewMode = mode;
|
result.diffViewMode = mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.gitChangesViewMode === 'string') {
|
||||||
|
const mode = candidate.gitChangesViewMode.trim();
|
||||||
|
if (mode === 'flat' || mode === 'tree') {
|
||||||
|
result.gitChangesViewMode = mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (typeof candidate.directoryShowHidden === 'boolean') {
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
||||||
result.directoryShowHidden = candidate.directoryShowHidden;
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user