Skip to content

Commit 0858ebf

Browse files
committed
feat: detect and handle missing project folders
On startup, validate each project's folder exists via CheckPathExists IPC. Show visual warnings in sidebar and edit dialog when a folder is missing, with options to re-link to a new path or safely remove the project. Block task creation (both worktree and direct mode) for missing projects.
1 parent 382216d commit 0858ebf

8 files changed

Lines changed: 127 additions & 5 deletions

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
createTerminal,
4242
closeTerminal,
4343
setNewTaskDropUrl,
44+
validateProjectPaths,
4445
} from './store/store';
4546
import { isGitHubUrl } from './lib/github-url';
4647
import type { PersistedWindowState } from './store/types';
@@ -285,6 +286,7 @@ function App() {
285286

286287
await loadAgents();
287288
await loadState();
289+
await validateProjectPaths();
288290
await restoreWindowState();
289291
await captureWindowState();
290292
setupAutosave();

src/components/EditProjectDialog.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { createSignal, createEffect, For, Show } from 'solid-js';
22
import { Dialog } from './Dialog';
3-
import { updateProject, PASTEL_HUES } from '../store/store';
3+
import {
4+
updateProject,
5+
PASTEL_HUES,
6+
isProjectMissing,
7+
relinkProject,
8+
removeProjectWithTasks,
9+
} from '../store/store';
410
import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name';
511
import { theme } from '../lib/theme';
612
import type { Project, TerminalBookmark } from '../store/types';
@@ -103,6 +109,62 @@ export function EditProjectDialog(props: EditProjectDialogProps) {
103109
{project().path}
104110
</div>
105111

112+
<Show when={isProjectMissing(project().id)}>
113+
<div
114+
style={{
115+
display: 'flex',
116+
'align-items': 'center',
117+
gap: '10px',
118+
padding: '10px 14px',
119+
'border-radius': '8px',
120+
background: `color-mix(in srgb, ${theme.warning} 10%, transparent)`,
121+
border: `1px solid color-mix(in srgb, ${theme.warning} 30%, transparent)`,
122+
color: theme.warning,
123+
'font-size': '12px',
124+
}}
125+
>
126+
<span style={{ flex: '1' }}>This folder no longer exists.</span>
127+
<button
128+
type="button"
129+
onClick={async () => {
130+
const ok = await relinkProject(project().id);
131+
if (ok) props.onClose();
132+
}}
133+
style={{
134+
padding: '5px 12px',
135+
background: theme.bgInput,
136+
border: `1px solid ${theme.border}`,
137+
'border-radius': '6px',
138+
color: theme.fg,
139+
cursor: 'pointer',
140+
'font-size': '12px',
141+
'flex-shrink': '0',
142+
}}
143+
>
144+
Re-link
145+
</button>
146+
<button
147+
type="button"
148+
onClick={async () => {
149+
await removeProjectWithTasks(project().id);
150+
props.onClose();
151+
}}
152+
style={{
153+
padding: '5px 12px',
154+
background: 'transparent',
155+
border: `1px solid color-mix(in srgb, ${theme.error} 40%, transparent)`,
156+
'border-radius': '6px',
157+
color: theme.error,
158+
cursor: 'pointer',
159+
'font-size': '12px',
160+
'flex-shrink': '0',
161+
}}
162+
>
163+
Remove
164+
</button>
165+
</div>
166+
</Show>
167+
106168
{/* Name */}
107169
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
108170
<label

src/components/Sidebar.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
setPanelSizes,
2020
toggleSettingsDialog,
2121
uncollapseTask,
22+
isProjectMissing,
2223
} from '../store/store';
2324
import type { Project } from '../store/types';
2425
import { ConnectPhoneModal } from './ConnectPhoneModal';
@@ -376,7 +377,9 @@ export function Sidebar() {
376377
gap: '6px',
377378
padding: '4px 6px',
378379
'border-radius': '6px',
379-
background: theme.bgInput,
380+
background: isProjectMissing(project.id)
381+
? `color-mix(in srgb, ${theme.warning} 8%, ${theme.bgInput})`
382+
: theme.bgInput,
380383
'font-size': sf(11),
381384
cursor: 'pointer',
382385
border:
@@ -408,14 +411,16 @@ export function Sidebar() {
408411
</div>
409412
<div
410413
style={{
411-
color: theme.fgSubtle,
414+
color: isProjectMissing(project.id) ? theme.warning : theme.fgSubtle,
412415
'font-size': sf(10),
413416
'white-space': 'nowrap',
414417
overflow: 'hidden',
415418
'text-overflow': 'ellipsis',
416419
}}
417420
>
418-
{abbreviatePath(project.path)}
421+
{isProjectMissing(project.id)
422+
? 'Folder not found'
423+
: abbreviatePath(project.path)}
419424
</div>
420425
</div>
421426
<button

src/store/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const [store, setStore] = createStore<AppStore>({
4444
editorCommand: '',
4545
newTaskDropUrl: null,
4646
newTaskPrefillPrompt: null,
47+
missingProjectIds: {},
4748
remoteAccess: {
4849
enabled: false,
4950
token: null,

src/store/projects.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { produce } from 'solid-js/store';
22
import { openDialog } from '../lib/dialog';
3+
import { invoke } from '../lib/ipc';
4+
import { IPC } from '../../electron/ipc/channels';
35
import { store, setStore } from './core';
46
import { closeTask } from './tasks';
57
import type { Project } from './types';
@@ -36,6 +38,7 @@ export function removeProject(projectId: string): void {
3638
if (s.lastProjectId === projectId) {
3739
s.lastProjectId = s.projects[0]?.id ?? null;
3840
}
41+
delete s.missingProjectIds[projectId];
3942
}),
4043
);
4144
}
@@ -115,3 +118,46 @@ export async function pickAndAddProject(): Promise<string | null> {
115118
const name = segments[segments.length - 1] || path;
116119
return addProject(name, path);
117120
}
121+
122+
/** Check each project path and record which ones are missing. */
123+
export async function validateProjectPaths(): Promise<void> {
124+
const missing: Record<string, true> = {};
125+
for (const project of store.projects) {
126+
try {
127+
const exists = await invoke<boolean>(IPC.CheckPathExists, { path: project.path });
128+
if (!exists) missing[project.id] = true;
129+
} catch {
130+
missing[project.id] = true;
131+
}
132+
}
133+
setStore('missingProjectIds', missing);
134+
}
135+
136+
/** Let the user pick a new folder for a project whose path is missing. */
137+
export async function relinkProject(projectId: string): Promise<boolean> {
138+
const selected = await openDialog({ directory: true, multiple: false });
139+
if (!selected) return false;
140+
const newPath = selected as string;
141+
142+
setStore(
143+
produce((s) => {
144+
const idx = s.projects.findIndex((p) => p.id === projectId);
145+
if (idx === -1) return;
146+
s.projects[idx].path = newPath;
147+
}),
148+
);
149+
150+
const exists = await invoke<boolean>(IPC.CheckPathExists, { path: newPath });
151+
if (exists) {
152+
setStore('missingProjectIds', (prev: Record<string, true>) => {
153+
const next = { ...prev };
154+
delete next[projectId];
155+
return next;
156+
});
157+
}
158+
return exists;
159+
}
160+
161+
export function isProjectMissing(projectId: string): boolean {
162+
return projectId in store.missingProjectIds;
163+
}

src/store/store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export {
99
getProjectPath,
1010
getProjectBranchPrefix,
1111
pickAndAddProject,
12+
validateProjectPaths,
13+
relinkProject,
14+
isProjectMissing,
1215
PASTEL_HUES,
1316
} from './projects';
1417
export {

src/store/tasks.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { invoke } from '../lib/ipc';
33
import { IPC } from '../../electron/ipc/channels';
44
import { store, setStore, updateWindowTitle, cleanupPanelEntries } from './core';
55
import { setTaskFocusedPanel } from './focus';
6-
import { getProject, getProjectPath, getProjectBranchPrefix } from './projects';
6+
import { getProject, getProjectPath, getProjectBranchPrefix, isProjectMissing } from './projects';
77
import { setPendingShellCommand } from '../lib/bookmarks';
88
import {
99
markAgentSpawned,
@@ -71,6 +71,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
7171
} = opts;
7272
const projectRoot = getProjectPath(projectId);
7373
if (!projectRoot) throw new Error('Project not found');
74+
if (isProjectMissing(projectId)) throw new Error('Project folder not found');
7475

7576
const branchPrefix = opts.branchPrefixOverride ?? getProjectBranchPrefix(projectId);
7677
const result = await invoke<CreateTaskResult>(IPC.CreateTask, {
@@ -145,6 +146,7 @@ export async function createDirectTask(opts: CreateDirectTaskOptions): Promise<s
145146
}
146147
const projectRoot = getProjectPath(projectId);
147148
if (!projectRoot) throw new Error('Project not found');
149+
if (isProjectMissing(projectId)) throw new Error('Project folder not found');
148150

149151
const id = crypto.randomUUID();
150152
const agentId = crypto.randomUUID();

src/store/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export interface AppStore {
175175
editorCommand: string;
176176
newTaskDropUrl: string | null;
177177
newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null;
178+
missingProjectIds: Record<string, true>;
178179
remoteAccess: RemoteAccess;
179180
showArena: boolean;
180181
}

0 commit comments

Comments
 (0)