Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/components/AppSelect.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -120,4 +132,4 @@
.app-select-menu__item[aria-selected="true"] {
background: var(--popup-active-background);
color: var(--popup-active-text);
}
}
2 changes: 2 additions & 0 deletions src/components/AppSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function AppSelect({
disabled = false,
ariaLabel,
className = "",
prefix,
size = "default",
menuPlacement = "auto",
}: AppSelectProps) {
Expand All @@ -52,6 +53,7 @@ export default function AppSelect({
className="app-select__button inline-flex min-w-0 items-center"
aria-label={ariaLabel}
>
{prefix ? <span className="app-select__prefix">{prefix}</span> : null}
<span className="app-select__value min-w-0 flex-1 truncate text-left">
{selected?.label ?? value}
</span>
Expand Down
78 changes: 78 additions & 0 deletions src/pages/git-client/gitChangeTree.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
});
});
131 changes: 131 additions & 0 deletions src/pages/git-client/gitChangeTree.ts
Original file line number Diff line number Diff line change
@@ -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<string, MutableDirectory>;
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<GitChangeTreeNode, { type: "directory" }> =
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);
}
Loading