Skip to content
Merged
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
49 changes: 49 additions & 0 deletions apps/desktop/src/state/sidebarPrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,57 @@ export const PREF = {
onlyProject: "rustcode.sidebar.onlyProject",
folded: "rustcode.sidebar.folded",
density: "rustcode.sidebar.density",
toolbar: "rustcode.sidebar.toolbar",
sort: "rustcode.sidebar.sort",
settleAfterDays: "rustcode.sidebar.settleAfterDays",
} as const;

/**
* How the Threads list orders the rows nobody arranged: `activity` puts what runs, then what asks
* you, then what came back unseen first (rows move as turns start and end); `recent` is by when
* each thread last changed; `title` is A–Z and never moves on its own.
*/
export type SidebarSort = "activity" | "recent" | "title";

export const SIDEBAR_SORTS: readonly { id: SidebarSort; label: string }[] = [
{ id: "activity", label: "Activity" },
{ id: "recent", label: "Last updated" },
{ id: "title", label: "Title" },
];

/** A stored sort, read leniently: anything unknown is the default, `activity`. */
export function sidebarSort(raw: unknown): SidebarSort {
return raw === "recent" || raw === "title" ? raw : "activity";
}

/** The sidebar's sort and its setter, shared by the toolbar's menu and ⌘K. */
export function useSidebarSort() {
const [raw, set] = usePref<string>(PREF.sort, "activity");
return [sidebarSort(raw), set as (next: SidebarSort) => void] as const;
}

/** How many idle days settle a thread on its own; 0 never does. */
export const SETTLE_AFTER_CHOICES: readonly { days: number; label: string }[] = [
{ days: 0, label: "Never" },
{ days: 1, label: "1 day" },
{ days: 3, label: "3 days" },
{ days: 7, label: "7 days" },
{ days: 14, label: "14 days" },
];

/** The default: three idle days, what the sidebar always did. */
export const SETTLE_AFTER_DEFAULT_DAYS = 3;

/** A stored day count, read leniently: anything but one of the choices is the default. */
export function settleAfterDays(raw: unknown): number {
return SETTLE_AFTER_CHOICES.some((c) => c.days === raw) ? (raw as number) : SETTLE_AFTER_DEFAULT_DAYS;
}

/** The idle time that settles a thread, in ms; 0 never settles one. */
export function settleAfterMs(days: number): number {
return days * 86_400_000;
}

/**
* How much a thread row says: `detailed` is the three-line row (title, where it runs, the branch),
* `compact` one line (status, harness, title, age) with the rest on its hover card.
Expand Down
46 changes: 44 additions & 2 deletions apps/desktop/src/state/sidebarRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ export interface OrderKey {
pinRank?: number;
/** When its snooze ends. */
wakeAt?: number;
/** Its title, for the `title` sort. */
title?: string;
}

/** After every real time: a thread with no mark of its own sorts behind the ones that have one. */
Expand All @@ -486,13 +488,53 @@ const LAST = Number.MAX_SAFE_INTEGER;
* and a row the user arranged still keeps its place over this order
* (`state/sidebarOrder.ts` `applyOrder`).
*/
export function sortSection<T>(section: SectionId, items: readonly T[], key: (item: T) => OrderKey): T[] {
export function sortSection<T>(
section: SectionId,
items: readonly T[],
key: (item: T) => OrderKey,
sort: "activity" | "recent" | "title" = "activity",
): T[] {
if (section === "settled") return [...items];
const keyed = items.map((item) => ({ item, at: key(item) }));
keyed.sort((a, b) => compare(section, a.at, b.at));
// The chosen sort orders the one Threads list; Pinned, Working and Snoozed keep their own order.
const own = section === "pinned" || section === "working" || section === "snoozed";
keyed.sort((a, b) =>
own || sort === "activity"
? compare(section, a.at, b.at)
: sort === "recent"
? b.at.backAt - a.at.backAt
: (a.at.title ?? "").localeCompare(b.at.title ?? "", undefined, { sensitivity: "base" }) ||
b.at.backAt - a.at.backAt,
);
return keyed.map((row) => row.item);
}

/** What the sidebar's search reads of a thread: its title, project folder and branch. */
export interface Searchable {
title?: string;
cwd?: string;
worktree?: { branch?: string; repo?: string } | null;
}

/**
* Whether a thread matches the sidebar's search: every word of the query is in its title, its
* project's folder name or its branch, ignoring case. An empty query matches every thread.
*/
export function threadMatches(thread: Searchable, query: string): boolean {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return true;
const base = (path?: string) => path?.split(/[\\/]/).filter(Boolean).pop() ?? "";
const hay = [
thread.title ?? "",
base(thread.cwd),
base(thread.worktree?.repo),
thread.worktree?.branch ?? "",
]
.join(" ")
.toLowerCase();
return words.every((word) => hay.includes(word));
}

function compare(section: SectionId, a: OrderKey, b: OrderKey): number {
switch (section) {
case "working":
Expand Down
71 changes: 71 additions & 0 deletions apps/desktop/src/state/sidebarToolbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { settleAfterDays, settleAfterMs, sidebarSort } from "./sidebarPrefs";
import { type OrderKey, sortSection, threadMatches } from "./sidebarRows";
import { idleSettled } from "./thread";

const DAY = 86_400_000;

describe("the sidebar's sort", () => {
const rows: { id: string; key: OrderKey }[] = [
{ id: "old-running", key: { backAt: 1, running: true, title: "zeta" } },
{ id: "new", key: { backAt: 30, title: "Alpha" } },
{ id: "mid-unseen", key: { backAt: 20, unseen: true, title: "beta" } },
];
const order = (sort: "activity" | "recent" | "title", section: "threads" | "pinned" = "threads") =>
sortSection(section, rows, (r) => r.key, sort).map((r) => r.id);

test("activity leads with what runs, then what came back unseen", () => {
expect(order("activity")).toEqual(["old-running", "mid-unseen", "new"]);
});

test("last updated ignores what runs: only when each thread last changed", () => {
expect(order("recent")).toEqual(["new", "mid-unseen", "old-running"]);
});

test("title is A to Z, ignoring case", () => {
expect(order("title")).toEqual(["new", "mid-unseen", "old-running"]);
});

test("a stored sort is read leniently", () => {
expect(sidebarSort("title")).toBe("title");
expect(sidebarSort("nonsense")).toBe("activity");
expect(sidebarSort(undefined)).toBe("activity");
});
});

describe("the sidebar's search", () => {
const thread = {
title: "Fix the flaky CI job",
cwd: "/home/me/code/rustcode",
worktree: { branch: "fix/ci-flake", repo: "/home/me/code/rustcode" },
};

test("every word must be in the title, the project or the branch", () => {
expect(threadMatches(thread, "")).toBe(true);
expect(threadMatches(thread, "flaky")).toBe(true);
expect(threadMatches(thread, "RUSTCODE ci")).toBe(true);
expect(threadMatches(thread, "ci-flake")).toBe(true);
expect(threadMatches(thread, "flaky website")).toBe(false);
});

test("only the folder's name counts, not its parents", () => {
expect(threadMatches(thread, "home")).toBe(false);
});
});

describe("settling idle threads", () => {
test("after the chosen days, and never at 0", () => {
const now = 100 * DAY;
expect(idleSettled(now - 2 * DAY, now)).toBe(false);
expect(idleSettled(now - 3 * DAY, now)).toBe(true);
expect(idleSettled(now - 2 * DAY, now, settleAfterMs(1))).toBe(true);
expect(idleSettled(now - 90 * DAY, now, settleAfterMs(0))).toBe(false);
});

test("a stored day count is one of the choices, else the default", () => {
expect(settleAfterDays(7)).toBe(7);
expect(settleAfterDays(0)).toBe(0);
expect(settleAfterDays(5)).toBe(3);
expect(settleAfterDays("7")).toBe(3);
});
});
9 changes: 6 additions & 3 deletions apps/desktop/src/state/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,9 +1301,12 @@ export function formatDuration(ms: number): string {
/** How long a thread rests untouched before the sidebar settles it on its own. */
export const SETTLE_AFTER_MS = 3 * 86_400_000;

/** Whether a thread last active at `at` has been idle long enough to settle. */
export function idleSettled(at: number | undefined, now = Date.now()): boolean {
return at !== undefined && now - at >= SETTLE_AFTER_MS;
/**
* Whether a thread last active at `at` has been idle long enough to settle. `afterMs` is the
* user's choice (Settings › Appearance › Settle idle threads); 0 never settles one.
*/
export function idleSettled(at: number | undefined, now = Date.now(), afterMs = SETTLE_AFTER_MS): boolean {
return afterMs > 0 && at !== undefined && now - at >= afterMs;
}

/** A running turn's clock: `0:42`, `12:03`, `1:02:03`. */
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/ui/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ import { ShortcutsPage } from "./ShortcutSettings";

const StatisticsPage = lazy(() => import("./StatisticsPage").then((m) => ({ default: m.StatisticsPage })));

import { SidebarDensitySetting } from "./SidebarDensitySetting";
import { SettleAfterSetting, SidebarDensitySetting, SidebarToolbarSetting } from "./SidebarDensitySetting";
import { type SettingEntry, searchSettings } from "./settingsIndex";
import {
Action,
Expand Down Expand Up @@ -1337,6 +1337,8 @@ function AppearancePage() {
<Switch label="Reduce transparency" checked={reduceTransparency} onChange={setReduceTransparency} />
</Row>
<SidebarDensitySetting />
<SidebarToolbarSetting />
<SettleAfterSetting />
</Group>
<Group
title="Theme"
Expand Down
Loading
Loading