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 @@
1import {1import {
2 ChevronRightIcon,2 ChevronRightIcon,
3 FolderIcon,3 FolderIcon,
4 GripVerticalIcon,
4 GitPullRequestIcon,5 GitPullRequestIcon,
5 RocketIcon,6 RocketIcon,
6 SquarePenIcon,7 SquarePenIcon,
7 TerminalIcon,8 TerminalIcon,
8} from "lucide-react";9} from "lucide-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
10import {11import {
11 DEFAULT_MODEL,12 DEFAULT_MODEL,
12 type DesktopUpdateState,13 type DesktopUpdateState,
@@ -20,6 +21,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; · 7 unmodified lines
20import { useAppSettings } from "../appSettings";21import { useAppSettings } from "../appSettings";
21import { isElectron } from "../env";22import { isElectron } from "../env";
22import { APP_STAGE_LABEL } from "../branding";23import { APP_STAGE_LABEL } from "../branding";
24import { resolveServerHttpOrigin } from "../lib/serverOrigin";
23import { newCommandId, newProjectId, newThreadId } from "../lib/utils";25import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
24import { useStore } from "../store";26import { useStore } from "../store";
25import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";27import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
@@ -80,6 +82,11 @@ function formatRelativeTime(iso: string): string { · 54 unmodified lines
80 return `${Math.floor(hours / 24)}d ago`;82 return `${Math.floor(hours / 24)}d ago`;
81}83}
82 84
85function resolveProjectDropPosition(element: HTMLElement, clientY: number) {
86 const { top, height } = element.getBoundingClientRect();
87 return clientY < top + height / 2 ? "before" : "after";
88}
89
83interface ThreadStatusPill {90interface ThreadStatusPill {
84 label: "Working" | "Connecting" | "Completed" | "Pending Approval";91 label: "Working" | "Connecting" | "Completed" | "Pending Approval";
85 colorClass: string;92 colorClass: string;
@@ -212,29 +219,7 @@ function T3Wordmark() { · 126 unmodified lines
212 );219 );
213}220}
214 221
215/**222const serverHttpOrigin = resolveServerHttpOrigin();
216 * Derives the server's HTTP origin (scheme + host + port) from the same
217 * sources WsTransport uses, converting ws(s) to http(s).
218 */
219function 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
237const serverHttpOrigin = getServerHttpOrigin();
238 223
239function ProjectFavicon({ cwd }: { cwd: string }) {224function ProjectFavicon({ cwd }: { cwd: string }) {
240 const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");225 const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
@@ -260,6 +245,7 @@ export default function Sidebar() { · 19 unmodified lines
260 const projects = useStore((store) => store.projects);245 const projects = useStore((store) => store.projects);
261 const threads = useStore((store) => store.threads);246 const threads = useStore((store) => store.threads);
262 const markThreadUnread = useStore((store) => store.markThreadUnread);247 const markThreadUnread = useStore((store) => store.markThreadUnread);
248 const moveProject = useStore((store) => store.moveProject);
263 const toggleProject = useStore((store) => store.toggleProject);249 const toggleProject = useStore((store) => store.toggleProject);
264 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);250 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
265 const getDraftThreadByProjectId = useComposerDraftStore(251 const getDraftThreadByProjectId = useComposerDraftStore(
@@ -294,6 +280,11 @@ export default function Sidebar() { · 28 unmodified lines
294 const [isAddingProject, setIsAddingProject] = useState(false);280 const [isAddingProject, setIsAddingProject] = useState(false);
295 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);281 const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
296 const [renamingTitle, setRenamingTitle] = useState("");282 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);
297 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<288 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
298 ReadonlySet<ProjectId>289 ReadonlySet<ProjectId>
299 >(() => new Set());290 >(() => new Set());
@@ -967,6 +958,65 @@ export default function Sidebar() { · 667 unmodified lines
967 });958 });
968 }, []);959 }, []);
969 960
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
970 const wordmark = (1020 const wordmark = (
971 <div className="flex items-center gap-2">1021 <div className="flex items-center gap-2">
972 <SidebarTrigger className="shrink-0 md:hidden" />1022 <SidebarTrigger className="shrink-0 md:hidden" />
@@ -1032,6 +1082,12 @@ export default function Sidebar() { · 59 unmodified lines
1032 hasHiddenThreads && !isThreadListExpanded1082 hasHiddenThreads && !isThreadListExpanded
1033 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)1083 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
1034 : projectThreads;1084 : projectThreads;
1085 const dropIndicator =
1086 draggedProjectId !== null &&
1087 draggedProjectId !== project.id &&
1088 projectDropTarget?.projectId === project.id
1089 ? projectDropTarget.position
1090 : null;
1035 1091
1036 return (1092 return (
1037 <Collapsible1093 <Collapsible
@@ -1043,13 +1099,39 @@ export default function Sidebar() { · 5 unmodified lines
1043 toggleProject(project.id);1099 toggleProject(project.id);
1044 }}1100 }}
1045 >1101 >
1046 <SidebarMenuItem>1102 <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}>
1047 <div className="group/project-header relative">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>
1048 <CollapsibleTrigger1130 <CollapsibleTrigger
1049 render={1131 render={
1050 <SidebarMenuButton1132 <SidebarMenuButton
1051 size="sm"1133 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"
1053 />1135 />
1054 }1136 }
1055 onContextMenu={(event) => {1137 onContextMenu={(event) => {
apps/web/src/lib/serverOrigin.ts+19−0
@@ -0,0 +1,19 @@
1export 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 @@
1import type { OrchestrationReadModel } from "@t3tools/contracts";
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";2import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
3 4
4import { markThreadUnread, type AppState } from "./store";5import { markThreadUnread, moveProject, syncServerReadModel, type AppState } from "./store";
6import type { Project } from "./types";
5import type { Thread } from "./types";7import type { Thread } from "./types";
6 8
7function makeThread(overrides: Partial<Thread> = {}): Thread {9function makeThread(overrides: Partial<Thread> = {}): Thread {
@@ -24,18 +26,22 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 16 unmodified lines
24 };26 };
25}27}
26 28
27function makeState(thread: Thread): AppState {29function makeProject(overrides: Partial<Project> = {}): Project {
28 return {30 return {
29 projects: [31 id: ProjectId.makeUnsafe("project-1"),
30 {32 name: "Project",
31 id: ProjectId.makeUnsafe("project-1"),33 cwd: "/tmp/project",
32 name: "Project",34 model: "gpt-5-codex",
33 cwd: "/tmp/project",35 expanded: true,
34 model: "gpt-5-codex",36 scripts: [],
35 expanded: true,37 ...overrides,
36 scripts: [],38 };
37 },39}
38 ],40
41function makeState(thread: Thread, projects: Project[] = [makeProject()]): AppState {
42 return {
43 projects,
44 projectOrder: projects.map((project) => project.id),
39 threads: [thread],45 threads: [thread],
40 threadsHydrated: true,46 threadsHydrated: true,
41 runtimeMode: "full-access",47 runtimeMode: "full-access",
@@ -81,4 +87,76 @@ describe("store pure functions", () => { · 39 unmodified lines
81 87
82 expect(next).toEqual(initialState);88 expect(next).toEqual(initialState);
83 });89 });
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 });
84});162});
apps/web/src/store.ts+83−31
@@ -1,6 +1,7 @@
1import { Fragment, type ReactNode, createElement, useEffect } from "react";1import { Fragment, type ReactNode, createElement, useEffect } from "react";
2import {2import {
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 ProjectId,
4 ProviderSessionId,5 ProviderSessionId,
5 ThreadId,6 ThreadId,
6 type OrchestrationReadModel,7 type OrchestrationReadModel,
@@ -15,11 +16,13 @@ import { · 8 unmodified lines
15 type RuntimeMode,16 type RuntimeMode,
16 type Thread,17 type Thread,
17} from "./types";18} from "./types";
19import { resolveServerHttpOrigin } from "./lib/serverOrigin";
18 20
19// ── State ────────────────────────────────────────────────────────────21// ── State ────────────────────────────────────────────────────────────
20 22
21export interface AppState {23export interface AppState {
22 projects: Project[];24 projects: Project[];
25 projectOrder: Project["id"][];
23 threads: Thread[];26 threads: Thread[];
24 threadsHydrated: boolean;27 threadsHydrated: boolean;
25 runtimeMode: RuntimeMode;28 runtimeMode: RuntimeMode;
@@ -39,15 +42,19 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 13 unmodified lines
39 42
40const initialState: AppState = {43const initialState: AppState = {
41 projects: [],44 projects: [],
45 projectOrder: [],
42 threads: [],46 threads: [],
43 threadsHydrated: false,47 threadsHydrated: false,
44 runtimeMode: DEFAULT_RUNTIME_MODE,48 runtimeMode: DEFAULT_RUNTIME_MODE,
45};49};
46const persistedExpandedProjectCwds = new Set<string>();50const persistedExpandedProjectCwds = new Set<string>();
51const persistedProjectOrder: Project["id"][] = [];
47 52
48// ── Persist helpers ──────────────────────────────────────────────────53// ── Persist helpers ──────────────────────────────────────────────────
49 54
50function readPersistedState(): AppState {55function readPersistedState(): AppState {
56 persistedExpandedProjectCwds.clear();
57 persistedProjectOrder.length = 0;
51 if (typeof window === "undefined") return initialState;58 if (typeof window === "undefined") return initialState;
52 try {59 try {
53 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);60 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
@@ -55,15 +62,21 @@ function readPersistedState(): AppState { · 1 unmodified line
55 const parsed = JSON.parse(raw) as {62 const parsed = JSON.parse(raw) as {
56 runtimeMode?: RuntimeMode;63 runtimeMode?: RuntimeMode;
57 expandedProjectCwds?: string[];64 expandedProjectCwds?: string[];
65 projectOrder?: string[];
58 };66 };
59 persistedExpandedProjectCwds.clear();
60 for (const cwd of parsed.expandedProjectCwds ?? []) {67 for (const cwd of parsed.expandedProjectCwds ?? []) {
61 if (typeof cwd === "string" && cwd.length > 0) {68 if (typeof cwd === "string" && cwd.length > 0) {
62 persistedExpandedProjectCwds.add(cwd);69 persistedExpandedProjectCwds.add(cwd);
63 }70 }
64 }71 }
72 for (const projectId of parsed.projectOrder ?? []) {
73 if (typeof projectId === "string" && projectId.length > 0) {
74 persistedProjectOrder.push(ProjectId.makeUnsafe(projectId));
75 }
76 }
65 return {77 return {
66 ...initialState,78 ...initialState,
79 projectOrder: [...persistedProjectOrder],
67 runtimeMode:80 runtimeMode:
68 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"81 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"
69 ? parsed.runtimeMode82 ? parsed.runtimeMode
@@ -84,6 +97,7 @@ function persistState(state: AppState): void { · 14 unmodified lines
84 expandedProjectCwds: state.projects97 expandedProjectCwds: state.projects
85 .filter((project) => project.expanded)98 .filter((project) => project.expanded)
86 .map((project) => project.cwd),99 .map((project) => project.cwd),
100 projectOrder: state.projects.map((project) => project.id),
87 }),101 }),
88 );102 );
89 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {103 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
@@ -134,6 +148,28 @@ function mapProjectsFromReadModel( · 44 unmodified lines
134 });148 });
135}149}
136 150
151function 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
137function toLegacySessionStatus(173function toLegacySessionStatus(
138 status: OrchestrationSessionStatus,174 status: OrchestrationSessionStatus,
139): "connecting" | "ready" | "running" | "error" | "closed" {175): "connecting" | "ready" | "running" | "error" | "closed" {
@@ -157,34 +193,9 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { · 17 unmodified lines
157 return providerName === "claudeCode" ? "claudeCode" : "codex";193 return providerName === "claudeCode" ? "claudeCode" : "codex";
158}194}
159 195
160function 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
185function toAttachmentPreviewUrl(rawUrl: string): string {196function toAttachmentPreviewUrl(rawUrl: string): string {
186 if (rawUrl.startsWith("/")) {197 if (rawUrl.startsWith("/")) {
187 return `${resolveWsHttpOrigin()}${rawUrl}`;198 return `${resolveServerHttpOrigin()}${rawUrl}`;
188 }199 }
189 return rawUrl;200 return rawUrl;
190}201}
@@ -199,9 +210,10 @@ export function syncServerReadModel( · 8 unmodified lines
199 state: AppState,210 state: AppState,
200 readModel: OrchestrationReadModel,211 readModel: OrchestrationReadModel,
201): AppState {212): AppState {
202 const projects = mapProjectsFromReadModel(213 const activeReadModelProjects = readModel.projects.filter((project) => project.deletedAt === null);
203 readModel.projects.filter((project) => project.deletedAt === null),214 const projects = applyProjectOrder(
204 state.projects,215 mapProjectsFromReadModel(activeReadModelProjects, state.projects),
216 state.projectOrder,
205 );217 );
206 const existingThreadById = new Map(218 const existingThreadById = new Map(
207 state.threads.map((thread) => [thread.id, thread] as const),219 state.threads.map((thread) => [thread.id, thread] as const),
@@ -272,6 +284,7 @@ export function syncServerReadModel( · 64 unmodified lines
272 return {284 return {
273 ...state,285 ...state,
274 projects,286 projects,
287 projectOrder: projects.map((project) => project.id),
275 threads,288 threads,
276 threadsHydrated: true,289 threadsHydrated: true,
277 };290 };
@@ -333,6 +346,38 @@ export function setProjectExpanded( · 55 unmodified lines
333 return changed ? { ...state, projects } : state;346 return changed ? { ...state, projects } : state;
334}347}
335 348
349export 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
336export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {381export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
337 const threads = updateThread(state.threads, threadId, (t) => {382 const threads = updateThread(state.threads, threadId, (t) => {
338 if (t.error === error) return t;383 if (t.error === error) return t;
@@ -372,6 +417,11 @@ interface AppStore extends AppState { · 33 unmodified lines
372 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;417 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
373 markThreadUnread: (threadId: ThreadId) => void;418 markThreadUnread: (threadId: ThreadId) => void;
374 toggleProject: (projectId: Project["id"]) => void;419 toggleProject: (projectId: Project["id"]) => void;
420 moveProject: (
421 projectId: Project["id"],
422 targetProjectId: Project["id"],
423 position: "before" | "after",
424 ) => void;
375 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;425 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
376 setError: (threadId: ThreadId, error: string | null) => void;426 setError: (threadId: ThreadId, error: string | null) => void;
377 setThreadBranch: (427 setThreadBranch: (
@@ -392,6 +442,8 @@ export const useStore = create<AppStore>((set) => ({ · 14 unmodified lines
392 set((state) => markThreadUnread(state, threadId)),442 set((state) => markThreadUnread(state, threadId)),
393 toggleProject: (projectId) =>443 toggleProject: (projectId) =>
394 set((state) => toggleProject(state, projectId)),444 set((state) => toggleProject(state, projectId)),
445 moveProject: (projectId, targetProjectId, position) =>
446 set((state) => moveProject(state, projectId, targetProjectId, position)),
395 setProjectExpanded: (projectId, expanded) =>447 setProjectExpanded: (projectId, expanded) =>
396 set((state) => setProjectExpanded(state, projectId, expanded)),448 set((state) => setProjectExpanded(state, projectId, expanded)),
397 setError: (threadId, error) =>449 setError: (threadId, error) =>
@@ -402,7 +454,7 @@ export const useStore = create<AppStore>((set) => ({ · 4 unmodified lines
402 set((state) => setRuntimeMode(state, mode)),454 set((state) => setRuntimeMode(state, mode)),
403}));455}));
404 456
405// Persist on every state change (only runtimeMode + expandedProjectCwds)457// Persist on every state change (runtimeMode + expanded projects + project order)
406useStore.subscribe((state) => persistState(state));458useStore.subscribe((state) => persistState(state));
407 459
408export function StoreProvider({ children }: { children: ReactNode }) {460export 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