History

Add sidebar project reordering

Cursor Agent committed 8 months ago

Commit 02739bb1 parent d5c97fa4 files · +301 −70Browse files

Unified · Split

4 files
  1. apps/web/src/components/Sidebar.tsx +109 −27
  2. apps/web/src/lib/serverOrigin.ts +19 −0
  3. apps/web/src/store.test.ts +90 −12
  4. apps/web/src/store.ts +83 −31
apps/web/src/components/Sidebar.tsx+109−27
@@ -1,12 +1,13 @@
11 import {
22 ChevronRightIcon,
33 FolderIcon,
4+ GripVerticalIcon,
45 GitPullRequestIcon,
56 RocketIcon,
67 SquarePenIcon,
78 TerminalIcon,
89 } from "lucide-react";
9−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10+import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
1011 import {
1112 DEFAULT_MODEL,
1213 type DesktopUpdateState,
@@ -20,6 +21,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; · 7 unmodified lines
2021 import { useAppSettings } from "../appSettings";
2122 import { isElectron } from "../env";
2223 import { APP_STAGE_LABEL } from "../branding";
24+import { resolveServerHttpOrigin } from "../lib/serverOrigin";
2325 import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
2426 import { useStore } from "../store";
2527 import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
@@ -80,6 +82,11 @@ function formatRelativeTime(iso: string): string { · 54 unmodified lines
8082 return `${Math.floor(hours / 24)}d ago`;
8183 }
8284
85+function resolveProjectDropPosition(element: HTMLElement, clientY: number) {
86+ const { top, height } = element.getBoundingClientRect();
87+ return clientY < top + height / 2 ? "before" : "after";
88+}
89+
8390 interface ThreadStatusPill {
8491 label: "Working" | "Connecting" | "Completed" | "Pending Approval";
8592 colorClass: string;
@@ -212,29 +219,7 @@ function T3Wordmark() { · 126 unmodified lines
212219 );
213220 }
214221
215−/**
216− * Derives the server's HTTP origin (scheme + host + port) from the same
217− * sources WsTransport uses, converting ws(s) to http(s).
218− */
219−function getServerHttpOrigin(): string {
220− const bridgeUrl = window.desktopBridge?.getWsUrl();
221− const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
222− const wsUrl =
223− bridgeUrl && bridgeUrl.length > 0
224− ? bridgeUrl
225− : envUrl && envUrl.length > 0
226− ? envUrl
227− : `ws://${window.location.hostname}:${window.location.port}`;
228− // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
229− const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
230− try {
231− return new URL(httpUrl).origin;
232− } catch {
233− return httpUrl;
234− }
235−}
236−
237−const serverHttpOrigin = getServerHttpOrigin();
222+const serverHttpOrigin = resolveServerHttpOrigin();
238223
239224 function ProjectFavicon({ cwd }: { cwd: string }) {
240225 const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
@@ -260,6 +245,7 @@ export default function Sidebar() { · 19 unmodified lines
260245 const projects = useStore((store) => store.projects);
261246 const threads = useStore((store) => store.threads);
262247 const markThreadUnread = useStore((store) => store.markThreadUnread);
248+ const moveProject = useStore((store) => store.moveProject);
263249 const toggleProject = useStore((store) => store.toggleProject);
264250 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
265251 const getDraftThreadByProjectId = useComposerDraftStore(
@@ -294,6 +280,11 @@ export default function Sidebar() { · 28 unmodified lines
294280 const [isAddingProject, setIsAddingProject] = useState(false);
295281 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
296282 const [renamingTitle, setRenamingTitle] = useState("");
283+ const [draggedProjectId, setDraggedProjectId] = useState<ProjectId | null>(null);
284+ const [projectDropTarget, setProjectDropTarget] = useState<{
285+ projectId: ProjectId;
286+ position: "before" | "after";
287+ } | null>(null);
297288 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298289 ReadonlySet<ProjectId>
299290 >(() => new Set());
@@ -967,6 +958,65 @@ export default function Sidebar() { · 667 unmodified lines
967958 });
968959 }, []);
969960
961+ useEffect(() => {
962+ if (!draggedProjectId) return;
963+
964+ const previousUserSelect = document.body.style.userSelect;
965+ const previousCursor = document.body.style.cursor;
966+ document.body.style.userSelect = "none";
967+ document.body.style.cursor = "grabbing";
968+
969+ const handlePointerRelease = () => {
970+ setDraggedProjectId(null);
971+ setProjectDropTarget(null);
972+ };
973+
974+ window.addEventListener("pointerup", handlePointerRelease);
975+ window.addEventListener("pointercancel", handlePointerRelease);
976+ return () => {
977+ document.body.style.userSelect = previousUserSelect;
978+ document.body.style.cursor = previousCursor;
979+ window.removeEventListener("pointerup", handlePointerRelease);
980+ window.removeEventListener("pointercancel", handlePointerRelease);
981+ };
982+ }, [draggedProjectId]);
983+
984+ function resetProjectDragState() {
985+ setDraggedProjectId(null);
986+ setProjectDropTarget(null);
987+ }
988+
989+ function handleProjectDragStart(event: PointerEvent<HTMLButtonElement>, projectId: ProjectId) {
990+ if (event.button !== 0) return;
991+
992+ event.preventDefault();
993+ event.stopPropagation();
994+ setDraggedProjectId(projectId);
995+ setProjectDropTarget(null);
996+ }
997+
998+ function handleProjectDragOver(event: PointerEvent<HTMLElement>, projectId: ProjectId) {
999+ if (!draggedProjectId) return;
1000+
1001+ const position = resolveProjectDropPosition(event.currentTarget, event.clientY);
1002+ setProjectDropTarget((current) => {
1003+ if (current?.projectId === projectId && current.position === position) {
1004+ return current;
1005+ }
1006+ return { projectId, position };
1007+ });
1008+ }
1009+
1010+ function handleProjectDrop(event: PointerEvent<HTMLElement>, projectId: ProjectId) {
1011+ if (!draggedProjectId) return;
1012+
1013+ event.preventDefault();
1014+ event.stopPropagation();
1015+ const position = resolveProjectDropPosition(event.currentTarget, event.clientY);
1016+ moveProject(draggedProjectId, projectId, position);
1017+ resetProjectDragState();
1018+ }
1019+
9701020 const wordmark = (
9711021 <div className="flex items-center gap-2">
9721022 <SidebarTrigger className="shrink-0 md:hidden" />
@@ -1032,6 +1082,12 @@ export default function Sidebar() { · 59 unmodified lines
10321082 hasHiddenThreads && !isThreadListExpanded
10331083 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
10341084 : projectThreads;
1085+ const dropIndicator =
1086+ draggedProjectId !== null &&
1087+ draggedProjectId !== project.id &&
1088+ projectDropTarget?.projectId === project.id
1089+ ? projectDropTarget.position
1090+ : null;
10351091
10361092 return (
10371093 <Collapsible
@@ -1043,13 +1099,39 @@ export default function Sidebar() { · 5 unmodified lines
10431099 toggleProject(project.id);
10441100 }}
10451101 >
1046− <SidebarMenuItem>
1047− <div className="group/project-header relative">
1102+ <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}>
1103+ {dropIndicator === "before" && (
1104+ <div className="pointer-events-none absolute inset-x-2 top-0 h-0.5 rounded-full bg-primary" />
1105+ )}
1106+ {dropIndicator === "after" && (
1107+ <div className="pointer-events-none absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-primary" />
1108+ )}
1109+ <div
1110+ className="group/project-header relative"
1111+ onPointerEnter={(event) => handleProjectDragOver(event, project.id)}
1112+ onPointerMove={(event) => handleProjectDragOver(event, project.id)}
1113+ onPointerUp={(event) => handleProjectDrop(event, project.id)}
1114+ >
1115+ <button
1116+ type="button"
1117+ aria-label={`Reorder ${project.name}`}
1118+ aria-grabbed={draggedProjectId === project.id}
1119+ className={`absolute top-1/2 left-1 z-10 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground/45 transition-colors hover:bg-accent hover:text-foreground ${
1120+ draggedProjectId === project.id ? "cursor-grabbing" : "cursor-grab"
1121+ }`}
1122+ onClick={(event) => {
1123+ event.preventDefault();
1124+ event.stopPropagation();
1125+ }}
1126+ onPointerDown={(event) => handleProjectDragStart(event, project.id)}
1127+ >
1128+ <GripVerticalIcon className="size-3.5" />
1129+ </button>
10481130 <CollapsibleTrigger
10491131 render={
10501132 <SidebarMenuButton
10511133 size="sm"
1052− className="gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground"
1134+ className="gap-2 py-1.5 pr-2 pl-7 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground"
10531135 />
10541136 }
10551137 onContextMenu={(event) => {
apps/web/src/lib/serverOrigin.ts+19−0
@@ -0,0 +1,19 @@
1+export function resolveServerHttpOrigin(): string {
2+ if (typeof window === "undefined") return "";
3+
4+ const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
5+ const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
6+ const wsUrl =
7+ typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0
8+ ? bridgeWsUrl
9+ : typeof envWsUrl === "string" && envWsUrl.length > 0
10+ ? envWsUrl
11+ : `ws://${window.location.hostname}:${window.location.port}`;
12+ const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
13+
14+ try {
15+ return new URL(httpUrl).origin;
16+ } catch {
17+ return httpUrl;
18+ }
19+}
apps/web/src/store.test.ts+90−12
@@ -1,7 +1,9 @@
1+import type { OrchestrationReadModel } from "@t3tools/contracts";
12 import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
23 import { describe, expect, it } from "vitest";
34
4−import { markThreadUnread, type AppState } from "./store";
5+import { markThreadUnread, moveProject, syncServerReadModel, type AppState } from "./store";
6+import type { Project } from "./types";
57 import type { Thread } from "./types";
68
79 function makeThread(overrides: Partial<Thread> = {}): Thread {
@@ -24,18 +26,22 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 16 unmodified lines
2426 };
2527 }
2628
27−function makeState(thread: Thread): AppState {
29+function makeProject(overrides: Partial<Project> = {}): Project {
2830 return {
29− projects: [
30− {
31− id: ProjectId.makeUnsafe("project-1"),
32− name: "Project",
33− cwd: "/tmp/project",
34− model: "gpt-5-codex",
35− expanded: true,
36− scripts: [],
37− },
38− ],
31+ id: ProjectId.makeUnsafe("project-1"),
32+ name: "Project",
33+ cwd: "/tmp/project",
34+ model: "gpt-5-codex",
35+ expanded: true,
36+ scripts: [],
37+ ...overrides,
38+ };
39+}
40+
41+function makeState(thread: Thread, projects: Project[] = [makeProject()]): AppState {
42+ return {
43+ projects,
44+ projectOrder: projects.map((project) => project.id),
3945 threads: [thread],
4046 threadsHydrated: true,
4147 runtimeMode: "full-access",
@@ -81,4 +87,76 @@ describe("store pure functions", () => { · 39 unmodified lines
8187
8288 expect(next).toEqual(initialState);
8389 });
90+
91+ it("moveProject reorders projects and persists the new order", () => {
92+ const firstProject = makeProject({
93+ id: ProjectId.makeUnsafe("project-1"),
94+ name: "First",
95+ cwd: "/tmp/first",
96+ });
97+ const secondProject = makeProject({
98+ id: ProjectId.makeUnsafe("project-2"),
99+ name: "Second",
100+ cwd: "/tmp/second",
101+ });
102+ const thirdProject = makeProject({
103+ id: ProjectId.makeUnsafe("project-3"),
104+ name: "Third",
105+ cwd: "/tmp/third",
106+ });
107+ const initialState = makeState(makeThread(), [firstProject, secondProject, thirdProject]);
108+
109+ const next = moveProject(
110+ initialState,
111+ ProjectId.makeUnsafe("project-3"),
112+ ProjectId.makeUnsafe("project-1"),
113+ "before",
114+ );
115+
116+ expect(next.projects.map((project) => project.id)).toEqual([
117+ ProjectId.makeUnsafe("project-3"),
118+ ProjectId.makeUnsafe("project-1"),
119+ ProjectId.makeUnsafe("project-2"),
120+ ]);
121+ expect(next.projectOrder).toEqual(next.projects.map((project) => project.id));
122+ });
123+
124+ it("syncServerReadModel reapplies a persisted project order to fresh snapshots", () => {
125+ const initialState = {
126+ ...makeState(makeThread(), []),
127+ projectOrder: [
128+ ProjectId.makeUnsafe("project-2"),
129+ ProjectId.makeUnsafe("project-1"),
130+ ],
131+ };
132+ const readModel = {
133+ projects: [
134+ {
135+ id: ProjectId.makeUnsafe("project-1"),
136+ title: "First",
137+ workspaceRoot: "/tmp/first",
138+ defaultModel: null,
139+ scripts: [],
140+ deletedAt: null,
141+ },
142+ {
143+ id: ProjectId.makeUnsafe("project-2"),
144+ title: "Second",
145+ workspaceRoot: "/tmp/second",
146+ defaultModel: null,
147+ scripts: [],
148+ deletedAt: null,
149+ },
150+ ],
151+ threads: [],
152+ } as unknown as OrchestrationReadModel;
153+
154+ const next = syncServerReadModel(initialState, readModel);
155+
156+ expect(next.projects.map((project) => project.id)).toEqual([
157+ ProjectId.makeUnsafe("project-2"),
158+ ProjectId.makeUnsafe("project-1"),
159+ ]);
160+ expect(next.projectOrder).toEqual(next.projects.map((project) => project.id));
161+ });
84162 });
apps/web/src/store.ts+83−31
@@ -1,6 +1,7 @@
11 import { Fragment, type ReactNode, createElement, useEffect } from "react";
22 import {
33 DEFAULT_MODEL,
4+ ProjectId,
45 ProviderSessionId,
56 ThreadId,
67 type OrchestrationReadModel,
@@ -15,11 +16,13 @@ import { · 8 unmodified lines
1516 type RuntimeMode,
1617 type Thread,
1718 } from "./types";
19+import { resolveServerHttpOrigin } from "./lib/serverOrigin";
1820
1921 // ── State ────────────────────────────────────────────────────────────
2022
2123 export interface AppState {
2224 projects: Project[];
25+ projectOrder: Project["id"][];
2326 threads: Thread[];
2427 threadsHydrated: boolean;
2528 runtimeMode: RuntimeMode;
@@ -39,15 +42,19 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 13 unmodified lines
3942
4043 const initialState: AppState = {
4144 projects: [],
45+ projectOrder: [],
4246 threads: [],
4347 threadsHydrated: false,
4448 runtimeMode: DEFAULT_RUNTIME_MODE,
4549 };
4650 const persistedExpandedProjectCwds = new Set<string>();
51+const persistedProjectOrder: Project["id"][] = [];
4752
4853 // ── Persist helpers ──────────────────────────────────────────────────
4954
5055 function readPersistedState(): AppState {
56+ persistedExpandedProjectCwds.clear();
57+ persistedProjectOrder.length = 0;
5158 if (typeof window === "undefined") return initialState;
5259 try {
5360 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
@@ -55,15 +62,21 @@ function readPersistedState(): AppState { · 1 unmodified line
5562 const parsed = JSON.parse(raw) as {
5663 runtimeMode?: RuntimeMode;
5764 expandedProjectCwds?: string[];
65+ projectOrder?: string[];
5866 };
59− persistedExpandedProjectCwds.clear();
6067 for (const cwd of parsed.expandedProjectCwds ?? []) {
6168 if (typeof cwd === "string" && cwd.length > 0) {
6269 persistedExpandedProjectCwds.add(cwd);
6370 }
6471 }
72+ for (const projectId of parsed.projectOrder ?? []) {
73+ if (typeof projectId === "string" && projectId.length > 0) {
74+ persistedProjectOrder.push(ProjectId.makeUnsafe(projectId));
75+ }
76+ }
6577 return {
6678 ...initialState,
79+ projectOrder: [...persistedProjectOrder],
6780 runtimeMode:
6881 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"
6982 ? parsed.runtimeMode
@@ -84,6 +97,7 @@ function persistState(state: AppState): void { · 14 unmodified lines
8497 expandedProjectCwds: state.projects
8598 .filter((project) => project.expanded)
8699 .map((project) => project.cwd),
100+ projectOrder: state.projects.map((project) => project.id),
87101 }),
88102 );
89103 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
@@ -134,6 +148,28 @@ function mapProjectsFromReadModel( · 44 unmodified lines
134148 });
135149 }
136150
151+function applyProjectOrder(projects: Project[], projectOrder: Project["id"][]): Project[] {
152+ if (projects.length < 2 || projectOrder.length === 0) {
153+ return projects;
154+ }
155+
156+ const remainingProjects = new Map(projects.map((project) => [project.id, project] as const));
157+ const orderedProjects: Project[] = [];
158+ for (const projectId of projectOrder) {
159+ const project = remainingProjects.get(projectId);
160+ if (!project) continue;
161+ orderedProjects.push(project);
162+ remainingProjects.delete(projectId);
163+ }
164+
165+ const nextProjects = [
166+ ...orderedProjects,
167+ ...projects.filter((project) => remainingProjects.has(project.id)),
168+ ];
169+ const orderChanged = nextProjects.some((project, index) => project !== projects[index]);
170+ return orderChanged ? nextProjects : projects;
171+}
172+
137173 function toLegacySessionStatus(
138174 status: OrchestrationSessionStatus,
139175 ): "connecting" | "ready" | "running" | "error" | "closed" {
@@ -157,34 +193,9 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { · 17 unmodified lines
157193 return providerName === "claudeCode" ? "claudeCode" : "codex";
158194 }
159195
160−function resolveWsHttpOrigin(): string {
161− if (typeof window === "undefined") return "";
162− const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
163− const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
164− const wsCandidate =
165− typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0
166− ? bridgeWsUrl
167− : typeof envWsUrl === "string" && envWsUrl.length > 0
168− ? envWsUrl
169− : null;
170− if (!wsCandidate) return window.location.origin;
171− try {
172− const wsUrl = new URL(wsCandidate);
173− const protocol =
174− wsUrl.protocol === "wss:"
175− ? "https:"
176− : wsUrl.protocol === "ws:"
177− ? "http:"
178− : wsUrl.protocol;
179− return `${protocol}//${wsUrl.host}`;
180− } catch {
181− return window.location.origin;
182− }
183−}
184−
185196 function toAttachmentPreviewUrl(rawUrl: string): string {
186197 if (rawUrl.startsWith("/")) {
187− return `${resolveWsHttpOrigin()}${rawUrl}`;
198+ return `${resolveServerHttpOrigin()}${rawUrl}`;
188199 }
189200 return rawUrl;
190201 }
@@ -199,9 +210,10 @@ export function syncServerReadModel( · 8 unmodified lines
199210 state: AppState,
200211 readModel: OrchestrationReadModel,
201212 ): AppState {
202− const projects = mapProjectsFromReadModel(
203− readModel.projects.filter((project) => project.deletedAt === null),
204− state.projects,
213+ const activeReadModelProjects = readModel.projects.filter((project) => project.deletedAt === null);
214+ const projects = applyProjectOrder(
215+ mapProjectsFromReadModel(activeReadModelProjects, state.projects),
216+ state.projectOrder,
205217 );
206218 const existingThreadById = new Map(
207219 state.threads.map((thread) => [thread.id, thread] as const),
@@ -272,6 +284,7 @@ export function syncServerReadModel( · 64 unmodified lines
272284 return {
273285 ...state,
274286 projects,
287+ projectOrder: projects.map((project) => project.id),
275288 threads,
276289 threadsHydrated: true,
277290 };
@@ -333,6 +346,38 @@ export function setProjectExpanded( · 55 unmodified lines
333346 return changed ? { ...state, projects } : state;
334347 }
335348
349+export function moveProject(
350+ state: AppState,
351+ projectId: Project["id"],
352+ targetProjectId: Project["id"],
353+ position: "before" | "after",
354+): AppState {
355+ if (projectId === targetProjectId) return state;
356+
357+ const currentIndex = state.projects.findIndex((project) => project.id === projectId);
358+ const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId);
359+ if (currentIndex < 0 || targetIndex < 0) return state;
360+
361+ const projects = [...state.projects];
362+ const [project] = projects.splice(currentIndex, 1);
363+ if (!project) return state;
364+
365+ const adjustedTargetIndex = projects.findIndex((entry) => entry.id === targetProjectId);
366+ if (adjustedTargetIndex < 0) return state;
367+
368+ const insertionIndex = position === "before" ? adjustedTargetIndex : adjustedTargetIndex + 1;
369+ projects.splice(insertionIndex, 0, project);
370+
371+ const orderChanged = projects.some((entry, index) => entry !== state.projects[index]);
372+ if (!orderChanged) return state;
373+
374+ return {
375+ ...state,
376+ projects,
377+ projectOrder: projects.map((entry) => entry.id),
378+ };
379+}
380+
336381 export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
337382 const threads = updateThread(state.threads, threadId, (t) => {
338383 if (t.error === error) return t;
@@ -372,6 +417,11 @@ interface AppStore extends AppState { · 33 unmodified lines
372417 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
373418 markThreadUnread: (threadId: ThreadId) => void;
374419 toggleProject: (projectId: Project["id"]) => void;
420+ moveProject: (
421+ projectId: Project["id"],
422+ targetProjectId: Project["id"],
423+ position: "before" | "after",
424+ ) => void;
375425 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
376426 setError: (threadId: ThreadId, error: string | null) => void;
377427 setThreadBranch: (
@@ -392,6 +442,8 @@ export const useStore = create<AppStore>((set) => ({ · 14 unmodified lines
392442 set((state) => markThreadUnread(state, threadId)),
393443 toggleProject: (projectId) =>
394444 set((state) => toggleProject(state, projectId)),
445+ moveProject: (projectId, targetProjectId, position) =>
446+ set((state) => moveProject(state, projectId, targetProjectId, position)),
395447 setProjectExpanded: (projectId, expanded) =>
396448 set((state) => setProjectExpanded(state, projectId, expanded)),
397449 setError: (threadId, error) =>
@@ -402,7 +454,7 @@ export const useStore = create<AppStore>((set) => ({ · 4 unmodified lines
402454 set((state) => setRuntimeMode(state, mode)),
403455 }));
404456
405−// Persist on every state change (only runtimeMode + expandedProjectCwds)
457+// Persist on every state change (runtimeMode + expanded projects + project order)
406458 useStore.subscribe((state) => persistState(state));
407459
408460 export function StoreProvider({ children }: { children: ReactNode }) {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t