diff --git a/src/components/AppSelect.scss b/src/components/AppSelect.scss index 50036f3..977f14b 100644 --- a/src/components/AppSelect.scss +++ b/src/components/AppSelect.scss @@ -42,6 +42,18 @@ text-align: left; } +.app-select__prefix { + flex-shrink: 0; + color: var(--secondary-text); + display: inline-flex; + align-items: center; + justify-content: center; + + span[class^='icon-'] { + font-size: 14px; + } +} + .app-select__chevron { flex-shrink: 0; color: var(--secondary-text); @@ -120,4 +132,4 @@ .app-select-menu__item[aria-selected="true"] { background: var(--popup-active-background); color: var(--popup-active-text); -} \ No newline at end of file +} diff --git a/src/components/AppSelect.tsx b/src/components/AppSelect.tsx index 739ead6..1e3a0ad 100644 --- a/src/components/AppSelect.tsx +++ b/src/components/AppSelect.tsx @@ -31,6 +31,7 @@ export default function AppSelect({ disabled = false, ariaLabel, className = "", + prefix, size = "default", menuPlacement = "auto", }: AppSelectProps) { @@ -52,6 +53,7 @@ export default function AppSelect({ className="app-select__button inline-flex min-w-0 items-center" aria-label={ariaLabel} > + {prefix ? {prefix} : null} {selected?.label ?? value} diff --git a/src/pages/git-client/gitChangeTree.test.ts b/src/pages/git-client/gitChangeTree.test.ts new file mode 100644 index 0000000..ef6488a --- /dev/null +++ b/src/pages/git-client/gitChangeTree.test.ts @@ -0,0 +1,78 @@ +import type { GitWorkingTreeFile } from "state"; +import { describe, expect, it } from "vitest"; +import { buildGitChangeTree } from "./gitChangeTree"; + +function changedFile(path: string): GitWorkingTreeFile { + return { + path, + indexStatus: " ", + worktreeStatus: "M", + status: "modified", + staged: false, + unstaged: true, + untracked: false, + }; +} + +describe("buildGitChangeTree", () => { + it("groups files by directory and sorts folders before files", () => { + const tree = buildGitChangeTree([ + changedFile("README.md"), + changedFile("src/zeta.ts"), + changedFile("src/alpha.ts"), + changedFile("assets/logo.svg"), + ]); + + expect(tree.map((node) => node.name)).toEqual([ + "assets", + "src", + "README.md", + ]); + expect(tree[1]).toMatchObject({ + type: "directory", + name: "src", + path: "src", + fileCount: 2, + }); + if (tree[1].type === "directory") { + expect(tree[1].children.map((node) => node.name)).toEqual([ + "alpha.ts", + "zeta.ts", + ]); + } + }); + + it("flattens single-child directory chains for a compact mobile tree", () => { + const tree = buildGitChangeTree([ + changedFile("src/features/profile/components/Avatar.tsx"), + changedFile("src/features/profile/components/Profile.tsx"), + ]); + + expect(tree).toHaveLength(1); + expect(tree[0]).toMatchObject({ + type: "directory", + name: "src / features / profile / components", + path: "src/features/profile/components", + fileCount: 2, + }); + }); + + it("handles Windows-style separators without changing operation paths", () => { + const file = changedFile("src\\pages\\Home.tsx"); + const tree = buildGitChangeTree([file]); + + expect(tree[0]).toMatchObject({ + type: "directory", + name: "src / pages", + path: "src/pages", + }); + if (tree[0].type === "directory") { + expect(tree[0].children[0]).toMatchObject({ + type: "file", + name: "Home.tsx", + path: "src\\pages\\Home.tsx", + file, + }); + } + }); +}); diff --git a/src/pages/git-client/gitChangeTree.ts b/src/pages/git-client/gitChangeTree.ts new file mode 100644 index 0000000..d04e457 --- /dev/null +++ b/src/pages/git-client/gitChangeTree.ts @@ -0,0 +1,131 @@ +import type { GitWorkingTreeFile } from "state"; + +export type GitChangeTreeNode = + | { + type: "directory"; + name: string; + path: string; + fileCount: number; + children: GitChangeTreeNode[]; + } + | { + type: "file"; + name: string; + path: string; + file: GitWorkingTreeFile; + }; + +type MutableDirectory = { + name: string; + path: string; + directories: Map; + files: GitChangeTreeNode[]; +}; + +const NAME_COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); + +function sortNodes(nodes: GitChangeTreeNode[]): GitChangeTreeNode[] { + return nodes.sort((left, right) => { + if (left.type !== right.type) return left.type === "directory" ? -1 : 1; + return NAME_COLLATOR.compare(left.name, right.name); + }); +} + +function finalizeDirectory(directory: MutableDirectory): GitChangeTreeNode[] { + const childDirectories: GitChangeTreeNode[] = Array.from( + directory.directories.values(), + ).map((child) => { + const children = finalizeDirectory(child); + return { + type: "directory", + name: child.name, + path: child.path, + fileCount: countFiles(children), + children, + }; + }); + + return sortNodes([...childDirectories, ...directory.files]); +} + +function countFiles(nodes: GitChangeTreeNode[]): number { + let count = 0; + for (const node of nodes) { + count += node.type === "file" ? 1 : node.fileCount; + } + return count; +} + +function flattenDirectory(node: GitChangeTreeNode): GitChangeTreeNode { + if (node.type === "file") return node; + + let flattened: GitChangeTreeNode = { + ...node, + children: node.children.map(flattenDirectory), + }; + + while ( + flattened.type === "directory" && + flattened.children.length === 1 && + flattened.children[0].type === "directory" + ) { + const onlyChild: Extract = + flattened.children[0]; + flattened = { + ...onlyChild, + name: `${flattened.name} / ${onlyChild.name}`, + }; + } + + return flattened; +} + +/** + * Builds the compact directory tree used by the Git changes view. + * Git paths are usually POSIX-style, but backslashes are accepted so changes + * from Windows hosts render with the same hierarchy. + */ +export function buildGitChangeTree( + files: GitWorkingTreeFile[], +): GitChangeTreeNode[] { + const root: MutableDirectory = { + name: "", + path: "", + directories: new Map(), + files: [], + }; + + for (const file of files) { + const parts = file.path.split(/[\\/]+/).filter(Boolean); + const fileName = parts.pop() || file.path; + let directory = root; + const pathParts: string[] = []; + + for (const part of parts) { + pathParts.push(part); + let child = directory.directories.get(part); + if (!child) { + child = { + name: part, + path: pathParts.join("/"), + directories: new Map(), + files: [], + }; + directory.directories.set(part, child); + } + directory = child; + } + + directory.files.push({ + type: "file", + name: fileName, + path: file.path, + file, + }); + } + + return finalizeDirectory(root).map(flattenDirectory); +} diff --git a/src/pages/git-client/index.tsx b/src/pages/git-client/index.tsx index b3897f6..dec43be 100644 --- a/src/pages/git-client/index.tsx +++ b/src/pages/git-client/index.tsx @@ -2,10 +2,12 @@ import "./style.scss"; import { pushPage } from "App"; import dialog from "bridge/dialog"; import AppMenu from "components/AppMenu"; +import AppSelect from "components/AppSelect"; import DiffView from "components/DiffView"; import EmptyState from "components/EmptyState"; import Loader from "components/Loader"; import Page from "components/Page"; +import { getFileIcon } from "lib/fileIcon"; import { normalizeRemoteWorkspacePath } from "lib/remotePath"; import { formatRelativeTime } from "lib/utils"; import CommitDetailPage from "pages/git-history/CommitDetail"; @@ -22,11 +24,20 @@ import { import BranchPicker from "./BranchPicker"; import { type BranchActionError, getBranchActionError } from "./branchErrors"; import CommitComposer from "./CommitComposer"; +import { buildGitChangeTree, type GitChangeTreeNode } from "./gitChangeTree"; type GitTab = "changes" | "history"; type Filter = "all" | "staged" | "unstaged"; +type ChangeGroup = "staged" | "unstaged"; +type ChangeViewMode = "list" | "tree"; const PAGE_SIZE = 30; +const GIT_VIEW_MODE_KEY = "shellular:git-changes-view-mode"; +const CHANGE_FILTER_OPTIONS = [ + { value: "all", label: "All changes" }, + { value: "staged", label: "Staged" }, + { value: "unstaged", label: "Unstaged" }, +]; const STATUS_LABEL: Record = { modified: "M", @@ -38,6 +49,25 @@ const STATUS_LABEL: Record = { ignored: "I", }; +function getInitialChangeViewMode(): ChangeViewMode { + try { + const stored = localStorage.getItem(GIT_VIEW_MODE_KEY); + if (stored === "list" || stored === "tree") return stored; + } catch { + // Storage can be unavailable in private or restricted web views. + } + + return window.matchMedia?.("(max-width: 640px)").matches ? "tree" : "list"; +} + +function saveChangeViewMode(mode: ChangeViewMode) { + try { + localStorage.setItem(GIT_VIEW_MODE_KEY, mode); + } catch { + // The view still switches for this session when persistence is unavailable. + } +} + interface Props { projectPath: string; projectName: string; @@ -502,6 +532,326 @@ export default function GitClientPage({ projectPath }: Props) { ); } +type RunChangeOperation = ( + op: GitOperation, + opts?: { files?: string[]; message?: string }, +) => void; + +interface GitChangeFileRowProps { + file: GitWorkingTreeFile; + group: ChangeGroup; + selected: boolean; + busy: boolean; + processing: boolean; + treeDepth?: number; + onToggle: (path: string) => void; + onOpenDiff: (file: GitWorkingTreeFile) => void; + run: RunChangeOperation; +} + +function GitChangeFileRow({ + file, + group, + selected, + busy, + processing, + treeDepth, + onToggle, + onOpenDiff, + run, +}: GitChangeFileRowProps) { + const isTreeRow = treeDepth !== undefined; + const fileName = file.path.split(/[\\/]/).pop() || file.path; + const treeStyle = isTreeRow + ? ({ + "--tree-indent": `${Math.min(treeDepth, 6) * 14}px`, + } as React.CSSProperties) + : undefined; + + const discard = async () => { + const ok = await dialog.confirm( + `Are you sure you want to discard all changes in "${fileName}"? This cannot be undone.`, + "Discard Changes", + ); + if (ok) run("discard", { files: [file.path] }); + }; + + return ( +
+ + +
+ {processing ? ( +
+ +
+ ) : group === "unstaged" ? ( + <> + + + + ) : ( + + )} +
+ {!isTreeRow && ( + + )} +
+ ); +} + +interface GitChangeTreeNodeViewProps { + node: GitChangeTreeNode; + depth: number; + group: ChangeGroup; + selected: Set; + busy: boolean; + processingPaths: Set; + collapsed: Set; + onToggleFolder: (path: string) => void; + onToggleFile: (path: string) => void; + onOpenDiff: (file: GitWorkingTreeFile) => void; + run: RunChangeOperation; +} + +function GitChangeTreeNodeView({ + node, + depth, + group, + selected, + busy, + processingPaths, + collapsed, + onToggleFolder, + onToggleFile, + onOpenDiff, + run, +}: GitChangeTreeNodeViewProps) { + if (node.type === "file") { + return ( + + ); + } + + const isCollapsed = collapsed.has(node.path); + const treeStyle = { + "--tree-indent": `${Math.min(depth, 6) * 14}px`, + } as React.CSSProperties; + + return ( +
+ + {!isCollapsed && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +interface GitChangeFileCollectionProps { + files: GitWorkingTreeFile[]; + group: ChangeGroup; + viewMode: ChangeViewMode; + selected: Set; + busy: boolean; + processingPaths: Set; + onToggleFile: (path: string) => void; + onOpenDiff: (file: GitWorkingTreeFile) => void; + run: RunChangeOperation; +} + +function GitChangeFileCollection({ + files, + group, + viewMode, + selected, + busy, + processingPaths, + onToggleFile, + onOpenDiff, + run, +}: GitChangeFileCollectionProps) { + const tree = useMemo( + () => (viewMode === "tree" ? buildGitChangeTree(files) : []), + [files, viewMode], + ); + const [collapsed, setCollapsed] = useState>(() => new Set()); + + const toggleFolder = useCallback((path: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + if (viewMode === "list") { + return ( +
+ {files.map((file) => ( + + ))} +
+ ); + } + + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} + interface ChangesTabProps { status: GitWorkingTreeStatus | null; loading: boolean; @@ -520,10 +870,7 @@ interface ChangesTabProps { canStage: boolean; canUnstage: boolean; selectedPaths: string[]; - run: ( - op: GitOperation, - opts?: { files?: string[]; message?: string }, - ) => void; + run: RunChangeOperation; lastOutput: string; processingPaths: Set; setSelected: React.Dispatch>>; @@ -558,6 +905,15 @@ function ChangesTab({ branchLoading, commitAndPush, }: ChangesTabProps) { + const [viewMode, setViewMode] = useState( + getInitialChangeViewMode, + ); + + const changeViewMode = useCallback((mode: ChangeViewMode) => { + setViewMode(mode); + saveChangeViewMode(mode); + }, []); + if (loading) { return ; } @@ -597,106 +953,6 @@ function ChangesTab({ }); }; - const handleDiscard = async (file: GitWorkingTreeFile) => { - const name = file.path.split("/").pop() || file.path; - const ok = await dialog.confirm( - `Are you sure you want to discard all changes in "${name}"? This cannot be undone.`, - "Discard Changes", - ); - if (ok) { - run("discard", { files: [file.path] }); - } - }; - - const renderFile = ( - file: GitWorkingTreeFile, - group: "staged" | "unstaged", - ) => ( -
- - -
- {processingPaths.has(file.path) ? ( -
- -
- ) : group === "unstaged" ? ( - <> - - - - ) : ( - - )} -
- -
- ); - return (
@@ -733,24 +989,40 @@ function ChangesTab({ {error &&
{error}
} {lastOutput &&
{lastOutput}
} -
-
- {(["all", "staged", "unstaged"] as Filter[]).map((item) => ( - - ))} +
+ setFilter(value as Filter)} + ariaLabel="Filter changes" + className="git-change-filter" + prefix={
@@ -782,9 +1054,17 @@ function ChangesTab({ Unstage all
-
- {stagedFiles.map((file) => renderFile(file, "staged"))} -
+ )} @@ -812,9 +1092,17 @@ function ChangesTab({ Stage all
-
- {unstagedFiles.map((file) => renderFile(file, "unstaged"))} -
+ )} diff --git a/src/pages/git-client/style.scss b/src/pages/git-client/style.scss index ef4b462..30f157e 100644 --- a/src/pages/git-client/style.scss +++ b/src/pages/git-client/style.scss @@ -379,38 +379,116 @@ white-space: pre-wrap; } -// ─── Filter row (full-width segmented control) ────────────────── +// ─── Compact filter and view controls ─────────────────────────── .git-filter-row { padding: 8px 16px 4px; } -.git-filter-tabs { +.git-view-toolbar { display: flex; - width: 100%; - background: var(--secondary); - border-radius: 10px; + align-items: center; + gap: 8px; +} + +.git-change-filter { + flex: 1; + min-width: 0; + max-width: 156px; + + .app-select__button { + height: 42px; + border-color: var(--border-color); + border-radius: 10px; + background: var(--secondary); + padding: 0 11px; + font-size: 12.5px; + font-weight: 650; + } + + .app-select__prefix { + color: var(--accent); + } +} + +.git-view-switch { + height: 42px; + margin-left: auto; padding: 3px; border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--secondary); + display: flex; + align-items: center; gap: 2px; + flex-shrink: 0; + + button { + width: 34px; + height: 34px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--secondary-text); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; + + &[data-active="true"] { + background: var(--surface-soft); + color: var(--primary-text); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); + } + + &:active { + transform: scale(0.94); + } + + > .icon-list { + font-size: 16px; + } + } } -.git-filter-tabs button { - flex: 1; - min-width: 0; - height: 34px; - border: none; - border-radius: 7px; - background: transparent; - color: var(--secondary-text); - font-size: 13px; - font-weight: 600; - cursor: pointer; - text-transform: capitalize; - transition: background 0.15s, color 0.15s; +.git-tree-mode-icon { + position: relative; + width: 17px; + height: 16px; + display: block; + + i { + position: absolute; + right: 0; + width: 9px; + height: 1.5px; + border-radius: 2px; + background: currentColor; + + &::before { + content: ""; + position: absolute; + top: -1.5px; + left: -5px; + width: 3px; + height: 3px; + border-radius: 1px; + background: currentColor; + } - &[data-active="true"] { - background: var(--accent); - color: var(--button-text); + &:nth-child(1) { + top: 1px; + width: 13px; + } + + &:nth-child(2) { + top: 7px; + } + + &:nth-child(3) { + top: 13px; + } } } @@ -531,6 +609,93 @@ flex-direction: column; } +.git-tree-list { + padding: 5px 0; + overflow: hidden; + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--accent) 4%, transparent), + transparent 42% + ), + var(--card-bg, transparent); +} + +.git-tree-directory { + min-width: 0; +} + +.git-tree-folder-row { + --tree-indent: 0px; + width: 100%; + min-height: 42px; + padding: 0 10px 0 calc(8px + var(--tree-indent)); + border: none; + border-radius: 0; + background: transparent; + color: var(--primary-text); + display: flex; + align-items: center; + gap: 7px; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + + &:active { + background: var(--surface-soft); + } +} + +.git-tree-folder-chevron { + width: 14px; + color: var(--secondary-text); + font-size: 13px; + opacity: 0.62; + flex-shrink: 0; + transition: transform 160ms cubic-bezier(0.2, 0, 0, 1); + + &--open { + transform: rotate(90deg); + } +} + +.git-tree-folder-icon { + width: 18px; + color: color-mix(in srgb, var(--accent) 82%, #f4b955); + font-size: 17px; + flex-shrink: 0; +} + +.git-tree-folder-name { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--primary-text); + font-size: 13px; + font-weight: 610; +} + +.git-tree-folder-count { + min-width: 19px; + height: 19px; + padding: 0 5px; + border-radius: 9px; + background: color-mix(in srgb, var(--surface-soft) 72%, transparent); + color: var(--secondary-text); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + flex-shrink: 0; +} + +.git-tree-children { + min-width: 0; +} + .git-file-row { --file-git-color: var(--secondary-text); display: flex; @@ -565,6 +730,77 @@ } } +.git-tree-file-row { + --tree-indent: 0px; + min-height: 44px; + padding: 1px 0 1px calc(8px + var(--tree-indent)); + border-bottom-color: color-mix( + in srgb, + var(--line-soft, var(--card-border)) 58%, + transparent + ); + + .git-file-check { + width: 34px; + height: 40px; + + span { + width: 16px; + height: 16px; + border-radius: 5px; + font-size: 10px; + } + } + + .git-file-main { + gap: 8px; + min-height: 40px; + padding: 4px 5px; + } + + .git-file-name { + font-size: 13px; + font-weight: 520; + } + + .git-file-actions { + gap: 2px; + padding-right: 5px; + } + + .git-file-action-btn, + .git-file-spinner { + width: 30px; + height: 30px; + } +} + +.git-tree-file-icon { + width: 17px; + color: var(--secondary-text); + font-size: 15px; + text-align: center; + opacity: 0.82; + flex-shrink: 0; +} + +.git-tree-file-status { + min-width: 15px; + color: var(--file-git-color); + font-family: "JetBrainsMono Nerd Font", monospace; + font-size: 11px; + font-weight: 750; + text-align: center; + flex-shrink: 0; +} + +@media (hover: hover) { + .git-tree-folder-row:hover, + .git-tree-file-row:hover { + background: color-mix(in srgb, var(--surface-soft) 66%, transparent); + } +} + .git-file-check { width: 44px; height: 44px; @@ -1436,6 +1672,41 @@ } @media (max-width: 520px) { + .git-view-toolbar { + padding-right: 12px; + padding-left: 12px; + } + + .git-change-filter { + max-width: 148px; + } + + .git-change-group { + margin-top: 6px; + } + + .git-group-header { + margin-right: 12px; + margin-left: 12px; + } + + .git-file-list { + margin-right: 12px; + margin-left: 12px; + } + + .git-tree-folder-row { + min-height: 44px; + } + + .git-tree-file-row { + min-height: 46px; + + .git-file-main { + min-height: 42px; + } + } + .git-modal-backdrop { align-items: flex-end; }