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
1 change: 0 additions & 1 deletion apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export function Sidebar({
}))}
folders={folders.map((folder) => ({
path: folder.path,
modifiedAt: folder.modified_at,
}))}
currentPath={currentPath ?? null}
sortMode={sortMode}
Expand Down
16 changes: 10 additions & 6 deletions packages/ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,7 @@ export function Sidebar({
</DragOverlay>
</DndContext>
);
const sortLabel = sortMode === "recent" ? "Recent" : "Name";

return (
<SidebarFrame onCollapse={onCollapse} storageScope={storageScope}>
Expand Down Expand Up @@ -1313,8 +1314,8 @@ export function Sidebar({
<Button
variant="ghost"
size="icon-xs"
aria-label="Sort by..."
title="Sort by..."
aria-label={`Sort files: ${sortLabel}`}
title={`Sort files: ${sortLabel}`}
/>
}
>
Expand All @@ -1332,9 +1333,6 @@ export function Sidebar({
className="isolate z-50"
>
<Select.Popup className="z-50 w-36 origin-(--transform-origin) rounded-[var(--radius-popover)] border border-border bg-popover p-1 text-[11px] text-popover-foreground shadow-overlay outline-hidden transition-[transform,opacity] data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
<p className="px-2 py-1 text-[10px] font-medium text-muted-foreground">
Sort by
</p>
<SortOption value="recent" label="Recent" />
<SortOption value="alpha" label="Name" />
</Select.Popup>
Expand Down Expand Up @@ -2441,7 +2439,13 @@ function stripMatchingExtension(name: string, extension: string) {
: name;
}

function SortOption({ value, label }: { value: string; label: string }) {
function SortOption({
value,
label,
}: {
value: SidebarSortMode;
label: string;
}) {
return (
<Select.Item
value={value}
Expand Down
127 changes: 111 additions & 16 deletions packages/ui/src/components/useSidebarTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
flattenRows,
type SidebarFile,
type SidebarRow,
type SidebarSortMode,
useSidebarTree,
} from "./useSidebarTree";

Expand Down Expand Up @@ -43,10 +44,8 @@ function folderNames(node: ReturnType<typeof buildFileTree>) {

describe("buildFileTree", () => {
it("includes empty folders from directory entries", () => {
const tree = buildFileTree(
[],
[{ path: "/workspace/empty", modifiedAt: 3 }],
(path) => path.replace("/workspace/", ""),
const tree = buildFileTree([], [{ path: "/workspace/empty" }], (path) =>
path.replace("/workspace/", ""),
);

expect(folderNames(tree)).toEqual(["empty"]);
Expand All @@ -56,10 +55,7 @@ describe("buildFileTree", () => {
it("includes folder-only nested hierarchies from directory entries", () => {
const tree = buildFileTree(
[],
[
{ path: "/workspace/parent", modifiedAt: 1 },
{ path: "/workspace/parent/child", modifiedAt: 2 },
],
[{ path: "/workspace/parent" }, { path: "/workspace/parent/child" }],
(path) => path.replace("/workspace/", ""),
);

Expand All @@ -83,10 +79,7 @@ describe("flattenRows", () => {
const getDisplayPath = (path: string) => path.replace("/workspace/", "");
const tree = buildFileTree(
[],
[
{ path: "/workspace/empty", modifiedAt: 1 },
{ path: "/workspace/empty/new-folder", modifiedAt: 2 },
],
[{ path: "/workspace/empty" }, { path: "/workspace/empty/new-folder" }],
getDisplayPath,
);

Expand All @@ -106,10 +99,7 @@ describe("flattenRows", () => {
const getDisplayPath = (path: string) => path.replace("/workspace/", "");
const tree = buildFileTree(
[],
[
{ path: "/workspace/empty", modifiedAt: 1 },
{ path: "/workspace/empty/new-folder", modifiedAt: 2 },
],
[{ path: "/workspace/empty" }, { path: "/workspace/empty/new-folder" }],
getDisplayPath,
);

Expand All @@ -123,6 +113,101 @@ describe("flattenRows", () => {

expect(rows.map((row) => row.label)).toEqual(["empty/new-folder"]);
});

it.each([
["recent", ["alpha.md", "charlie.md", "bravo.md"]],
["alpha", ["alpha.md", "bravo.md", "charlie.md"]],
] satisfies [
SidebarSortMode,
string[],
][])("sorts files by %s", (sortMode, expected) => {
const files = [
{ path: "/workspace/alpha.md", modifiedAt: 30 },
{ path: "/workspace/bravo.md", modifiedAt: 10 },
{ path: "/workspace/charlie.md", modifiedAt: 20 },
];
const tree = buildFileTree(files, [], getDisplayPath);

const rows = flattenRows({
files,
getDisplayPath,
tree,
sortMode,
expandedFolders: new Set(),
});

expect(fileLabels(rows)).toEqual(expected);
});

it.each([
"recent",
"alpha",
] satisfies SidebarSortMode[])("keeps folders alphabetical at every depth with %s file order", (sortMode) => {
const files = [
{ path: "/workspace/02 Stuff/new.md", modifiedAt: 30 },
{ path: "/workspace/00 Index/old.md", modifiedAt: 10 },
];
const folders = [
{ path: "/workspace/Parent/02 Stuff" },
{ path: "/workspace/Parent/00 Index" },
{ path: "/workspace/02 Stuff" },
{ path: "/workspace/00 Index" },
{ path: "/workspace/Parent" },
];
const tree = buildFileTree(files, folders, getDisplayPath);

const rows = flattenRows({
files,
getDisplayPath,
tree,
sortMode,
expandedFolders: new Set(["Parent/"]),
});

expect(folderLabels(rows, 0)).toEqual(["00 Index", "02 Stuff", "Parent"]);
expect(folderLabels(rows, 1)).toEqual(["00 Index", "02 Stuff"]);
});

it("sorts numeric names naturally", () => {
const files = [
{ path: "/workspace/10 Notes.md" },
{ path: "/workspace/2 Notes.md" },
];
const tree = buildFileTree(
files,
[{ path: "/workspace/10 Archive" }, { path: "/workspace/2 Archive" }],
getDisplayPath,
);

const rows = flattenRows({
files,
getDisplayPath,
tree,
sortMode: "alpha",
expandedFolders: new Set(),
});

expect(folderLabels(rows, 0)).toEqual(["2 Archive", "10 Archive"]);
expect(fileLabels(rows)).toEqual(["2 Notes.md", "10 Notes.md"]);
});

it("uses the selected file order for pinned files", () => {
const files = [
{ path: "/workspace/alpha.md", modifiedAt: 10, pinned: true },
{ path: "/workspace/bravo.md", modifiedAt: 30, pinned: true },
];
const tree = buildFileTree(files, [], getDisplayPath);

const rows = flattenRows({
files,
getDisplayPath,
tree,
sortMode: "recent",
expandedFolders: new Set(),
});

expect(fileLabels(rows)).toEqual(["bravo.md", "alpha.md"]);
});
});

describe("useSidebarTree", () => {
Expand Down Expand Up @@ -211,6 +296,16 @@ function filePaths(rows: SidebarRow[]) {
return rows.flatMap((row) => (row.kind === "file" ? [row.file.path] : []));
}

function fileLabels(rows: SidebarRow[]) {
return rows.flatMap((row) => (row.kind === "file" ? [row.label] : []));
}

function folderLabels(rows: SidebarRow[], depth: number) {
return rows.flatMap((row) =>
row.kind === "folder" && row.depth === depth ? [row.label] : [],
);
}

function folderRow(rows: SidebarRow[], id: string) {
const row = rows.find((row) => row.kind === "folder" && row.id === id);
if (!row || row.kind !== "folder") throw new Error(`Missing folder ${id}`);
Expand Down
41 changes: 18 additions & 23 deletions packages/ui/src/components/useSidebarTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ export type SidebarFile = {

export type SidebarFolder = {
path: string;
modifiedAt?: number;
};

type FolderNode = {
id: string;
name: string;
modifiedAt: number;
folders: Map<string, FolderNode>;
files: SidebarFile[];
};
Expand Down Expand Up @@ -157,7 +155,6 @@ function makeFolder(id: string, name: string): FolderNode {
return {
id,
name,
modifiedAt: 0,
folders: new Map(),
files: [],
};
Expand All @@ -175,10 +172,8 @@ export function buildFileTree(
if (!displayPath) continue;
const segments = displayPath.split("/").filter(Boolean);
let parent = root;
const modifiedAt = folderEntry.modifiedAt ?? 0;
for (const segment of segments) {
const folder = ensureFolder(parent, segment);
folder.modifiedAt = Math.max(folder.modifiedAt, modifiedAt);
parent = folder;
}
}
Expand All @@ -194,7 +189,6 @@ export function buildFileTree(
const modifiedAt = file.modifiedAt ?? 0;
for (const segment of segments) {
const folder = ensureFolder(parent, segment);
folder.modifiedAt = Math.max(folder.modifiedAt, modifiedAt);
parent = folder;
}

Expand All @@ -203,7 +197,6 @@ export function buildFileTree(
path: file.path,
modifiedAt,
});
parent.modifiedAt = Math.max(parent.modifiedAt, modifiedAt);
}

return root;
Expand Down Expand Up @@ -268,9 +261,7 @@ function appendFolderChildren(
rows: SidebarRow[],
uncompactFolderId: string | null,
) {
const folders = [...folder.folders.values()].sort((a, b) =>
compareNodes(a, b, sortMode),
);
const folders = [...folder.folders.values()].sort(compareFolders);
const files = [...folder.files].sort((a, b) => compareFiles(a, b, sortMode));

for (const child of folders) {
Expand Down Expand Up @@ -333,28 +324,32 @@ function compactFolder(
return { folder: cursor, label: names.join("/"), segments };
}

function compareNodes(
a: Pick<FolderNode, "name" | "modifiedAt">,
b: Pick<FolderNode, "name" | "modifiedAt">,
sortMode: SidebarSortMode,
) {
if (sortMode === "recent") {
const byModified = b.modifiedAt - a.modifiedAt;
if (byModified !== 0) return byModified;
}
return a.name.localeCompare(b.name);
function compareFolders(a: FolderNode, b: FolderNode) {
return compareNames(a.name, b.name);
}

function compareFiles(
a: SidebarFile,
b: SidebarFile,
sortMode: SidebarSortMode,
) {
const byName = compareNames(
fileNameFromPath(a.path),
fileNameFromPath(b.path),
);
if (sortMode === "recent") {
const byModified = (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0);
if (byModified !== 0) return byModified;
return (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0) || byName;
}
return fileNameFromPath(a.path).localeCompare(fileNameFromPath(b.path));
return byName;
}

const nameCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});

function compareNames(a: string, b: string) {
return nameCollator.compare(a, b) || a.localeCompare(b);
}

function getFolderAncestorIds(displayPath: string): Set<string> {
Expand Down
Loading