Skip to content

Commit a7d954a

Browse files
var-ggclaude
andcommitted
feat(panel): missing-repo detection + hide action (v0.1.1 prep 6/6)
Closes out the v0.1.1 discovery overhaul. Cached repos that disappear from disk now grey out instead of silently returning empty timelines, and a one-click ✕ lets the user tombstone a row so it doesn't keep showing up. Backend: * discovery_orchestrator::verify_existing_repos — runs at the head of each pipeline pass. Walks all cached rows whose user_state isn't 'removed', flips status to 'missing' for paths that don't exist on disk, restores 'active' (clears missing_since) for paths that came back. Cheap: just is_dir() per row, no git2 here — full re-validation happens via the regular tier flow when a candidate comes through. * discovery_orchestrator::hide_repo — sets user_state='removed' AND inserts into discovery_tombstones so the next prewarm doesn't auto- rediscover. Manual add (commit 5) already removes the tombstone, so the user can always bring a hidden repo back. * cache::list_repos filters out user_state='removed' (tombstoned) rows and now returns the status column so the UI can grey out 'missing'. * commands::hide_repo Tauri wrapper. * New event `timeline://repo-status` { canonicalPath, status } emitted on every transition (active → missing, missing → active, * → removed) so the frontend can patch state without a full reload. Frontend: * `Repo.status: "active" | "missing" | "removed"` added to types. * `hideRepo` in lib/ipc.ts. * RepoChip: - Missing rows render with 0.45 opacity name+path plus a small "· missing" suffix tag. - Pin star (★) is swapped for a hide button (✕) on missing rows — can't pin something that isn't there. - Tooltip on missing rows explains "moved or deleted on disk; drop the new path to relink, or click ✕ to hide". * App.tsx: - Listens to timeline://repo-status; patches allRepos in place (greys / restores / drops the row) and adjusts discoveredCount on removal. No full re-list_repos() round-trip needed. - onHide handler does an optimistic local remove, then calls hideRepo; on backend failure, falls back to a fresh list_repos() so the UI re-syncs with truth. - onRepoDiscovered now stamps status: "active" on the inserted Repo (matching the new type shape). CSS: - .chip-item-row.missing greys name + path. - .chip-item-missing-tag in muted red. - .chip-hide as the per-row hide button (transparent until hover). - Dark-mode pairs for all three. Rust tests 34/34. TS type-check clean. Completes the v0.1.1 discovery + drop/paste + lifecycle arc end-to-end — ready to tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db53a6c commit a7d954a

9 files changed

Lines changed: 314 additions & 14 deletions

File tree

src-tauri/src/cache.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ use tauri::{AppHandle, Manager};
99
use crate::git::CommitSummary;
1010

1111
#[derive(Debug, Clone, Serialize, Deserialize)]
12+
#[serde(rename_all = "camelCase")]
1213
pub struct Repo {
1314
pub path: String,
1415
pub name: String,
16+
/// Lifecycle status: "active" | "missing" | "removed". Frontend
17+
/// greys out "missing" rows and filters out "removed". Defaults to
18+
/// "active" for old rows that predate the lifecycle migration.
19+
#[serde(default = "default_status")]
20+
pub status: String,
21+
}
22+
23+
fn default_status() -> String {
24+
"active".to_string()
1525
}
1626

1727
/// Default ceiling for the diffs blob store. Older entries are evicted in
@@ -238,12 +248,23 @@ pub fn open(app: &AppHandle) -> Result<Connection> {
238248
// ----- repos -----
239249

240250
pub fn list_repos(conn: &Connection) -> Result<Vec<Repo>> {
241-
let mut stmt = conn.prepare("SELECT path, name FROM repos ORDER BY name COLLATE NOCASE")?;
251+
// Filter out user-removed (tombstoned) rows so a deleted repo
252+
// doesn't reappear in the chip dropdown. Missing rows DO come
253+
// through — the UI greys them out so the user can decide.
254+
let mut stmt = conn.prepare(
255+
r#"
256+
SELECT path, name, status
257+
FROM repos
258+
WHERE user_state != 'removed'
259+
ORDER BY name COLLATE NOCASE
260+
"#,
261+
)?;
242262
let rows = stmt
243263
.query_map([], |row| {
244264
Ok(Repo {
245265
path: row.get(0)?,
246266
name: row.get(1)?,
267+
status: row.get(2)?,
247268
})
248269
})?
249270
.collect::<Result<Vec<_>, _>>()?;

src-tauri/src/commands.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,23 @@ pub async fn explicit_add_repo(
177177
.map_err(|e| e.to_string())?
178178
}
179179

180+
/// Hide a repo from the panel and prevent auto-rediscovery. Tombstoned;
181+
/// the user can bring it back with explicit_add_repo. Used by the
182+
/// "hide" affordance on missing-status rows in the RepoChip.
183+
#[tauri::command]
184+
pub async fn hide_repo(
185+
app: AppHandle,
186+
canonical_path: String,
187+
) -> Result<(), String> {
188+
let app2 = app.clone();
189+
tauri::async_runtime::spawn_blocking(move || {
190+
discovery_orchestrator::hide_repo(&app2, &canonical_path)
191+
.map_err(|e| e.to_string())
192+
})
193+
.await
194+
.map_err(|e| e.to_string())?
195+
}
196+
180197
#[tauri::command]
181198
pub async fn changed_files(
182199
repo_path: String,
@@ -468,6 +485,7 @@ pub async fn discover_repos(app: AppHandle) -> Result<usize, String> {
468485
let repo = cache::Repo {
469486
path: path_str.clone(),
470487
name: name.clone(),
488+
status: "active".to_string(),
471489
};
472490
found.push(repo.clone());
473491
let _ = app.emit(

src-tauri/src/discovery_orchestrator.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ pub struct ScanProgressPayload {
5050
pub errors: usize,
5151
}
5252

53+
/// Repo lifecycle transition (active → missing or vice versa). Frontend
54+
/// uses this to grey out a row that moved/disappeared on disk, or to
55+
/// restore a row that came back.
56+
#[derive(Debug, Clone, Serialize)]
57+
#[serde(rename_all = "camelCase")]
58+
pub struct RepoStatusPayload {
59+
pub canonical_path: String,
60+
pub status: &'static str,
61+
}
62+
5363
/// Meta-table key that tracks whether the first-launch full scan has
5464
/// already completed. Lives in cache.db (not settings.json) so wiping
5565
/// the cache correctly re-triggers the full scan.
@@ -223,6 +233,99 @@ async fn run_discovery_async(app: AppHandle, cancel: CancellationToken) -> Resul
223233
}
224234
}
225235

236+
/// Walk active rows, check path existence, flip status accordingly.
237+
/// Emits one `timeline://repo-status` event per row that changed state
238+
/// so the frontend can grey out / restore individual rows without a
239+
/// full reload. Read-only: never deletes a row even if it's been gone
240+
/// forever — that's the user's call via hide/relink.
241+
fn verify_existing_repos(app: &AppHandle, conn: &mut Connection) -> Result<()> {
242+
let mut stmt = conn.prepare(
243+
r#"
244+
SELECT canonical_path, status
245+
FROM repos
246+
WHERE user_state NOT IN ('removed')
247+
AND status IN ('active', 'missing')
248+
"#,
249+
)?;
250+
let rows: Vec<(String, String)> = stmt
251+
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
252+
.collect::<Result<Vec<_>, _>>()?;
253+
drop(stmt);
254+
255+
let now = unix_now();
256+
let mut transitions: Vec<(String, &'static str)> = Vec::new();
257+
let tx = conn.transaction()?;
258+
for (path, status) in rows {
259+
let exists = Path::new(&path).is_dir();
260+
let new_status: &'static str = if exists { "active" } else { "missing" };
261+
if status == new_status {
262+
continue;
263+
}
264+
if new_status == "missing" {
265+
tx.execute(
266+
"UPDATE repos SET status = 'missing', missing_since = ?1 WHERE canonical_path = ?2",
267+
params![now, &path],
268+
)?;
269+
} else {
270+
tx.execute(
271+
"UPDATE repos SET status = 'active', missing_since = NULL, last_verified_at = ?1 WHERE canonical_path = ?2",
272+
params![now, &path],
273+
)?;
274+
}
275+
transitions.push((path, new_status));
276+
}
277+
tx.commit()?;
278+
279+
for (path, status) in transitions {
280+
let _ = app.emit(
281+
"timeline://repo-status",
282+
&RepoStatusPayload {
283+
canonical_path: path,
284+
status,
285+
},
286+
);
287+
}
288+
Ok(())
289+
}
290+
291+
/// Mark a repo as user-hidden. Sets user_state='removed' (so UI filters
292+
/// it out) and adds a tombstone (so future discovery passes don't auto-
293+
/// rediscover it). Returns the canonical path that was hidden, or an
294+
/// error if no such repo exists in cache.
295+
pub fn hide_repo(app: &AppHandle, canonical_path: &str) -> Result<()> {
296+
let mut conn = cache::open(app)?;
297+
let now = unix_now();
298+
let tx = conn.transaction()?;
299+
let updated = tx.execute(
300+
"UPDATE repos SET user_state = 'removed', removed_at = ?1 WHERE canonical_path = ?2",
301+
params![now, canonical_path],
302+
)?;
303+
if updated == 0 {
304+
return Err(anyhow::anyhow!("no such repo: {canonical_path}"));
305+
}
306+
tx.execute(
307+
r#"
308+
INSERT INTO discovery_tombstones (canonical_path, removed_at, reason)
309+
VALUES (?1, ?2, 'user_hide')
310+
ON CONFLICT(canonical_path) DO UPDATE SET
311+
removed_at = excluded.removed_at,
312+
reason = excluded.reason
313+
"#,
314+
params![canonical_path, now],
315+
)?;
316+
tx.commit()?;
317+
318+
let _ = app.emit(
319+
"timeline://repo-status",
320+
&RepoStatusPayload {
321+
canonical_path: canonical_path.to_string(),
322+
status: "removed",
323+
},
324+
);
325+
let _ = append_scan_log(app, &format!("{now} user_hide: {canonical_path}"));
326+
Ok(())
327+
}
328+
226329
/// Update the tray tooltip to reflect the current scan state. Best-
227330
/// effort: tray might not exist yet or the tooltip API might fail on
228331
/// some Linux distros — log to stderr if so but don't propagate.
@@ -252,6 +355,16 @@ fn run_pipeline_sync(
252355
cancel: CancellationToken,
253356
is_first_run: bool,
254357
) -> Result<(usize, usize)> {
358+
// Verification sweep: check active cached repos for path existence
359+
// BEFORE doing new discovery. Repos that disappeared on disk get
360+
// marked 'missing' so the UI can grey them out. Repos that came
361+
// back from 'missing' get restored. Path existence is cheap (no
362+
// git2 here) — full re-validation happens when a candidate comes
363+
// through the regular tier flow.
364+
if let Err(e) = verify_existing_repos(app, conn) {
365+
eprintln!("orchestrator verification sweep failed: {e:#}");
366+
}
367+
255368
let mut candidates: Vec<Candidate> = Vec::new();
256369

257370
// Tier 1: VS Code-family recents (VS Code / Insiders / Cursor / Windsurf).

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ pub fn run() {
257257
commands::list_branches,
258258
commands::current_upstream_status,
259259
commands::explicit_add_repo,
260+
commands::hide_repo,
260261
commands::repo_commits,
261262
commands::changed_files,
262263
commands::file_diff,

src/App.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
dismissPanel,
1313
explicitAddRepo,
1414
getPinnedRepos,
15+
hideRepo,
1516
listBranches,
1617
listRecentCommitsCached,
1718
listRepos,
@@ -166,6 +167,7 @@ function App() {
166167
let unProgress: UnlistenFn | undefined;
167168
let unDiscovered: UnlistenFn | undefined;
168169
let unFill: UnlistenFn | undefined;
170+
let unStatus: UnlistenFn | undefined;
169171

170172
(async () => {
171173
try {
@@ -204,7 +206,13 @@ function App() {
204206
if (!mounted) return;
205207
setAllRepos((prev) => {
206208
if (prev.some((r) => r.path === p.path)) return prev;
207-
const next = [...prev, { path: p.path, name: p.name }];
209+
// Orchestrator only emits for validated repos, so status='active'
210+
// is correct on insert. Status transitions later flip this via
211+
// the repo-status listener.
212+
const next = [
213+
...prev,
214+
{ path: p.path, name: p.name, status: "active" as const },
215+
];
208216
// Keep stable display order to avoid jitter in the chip dropdown.
209217
next.sort((a, b) => a.name.localeCompare(b.name));
210218
return next;
@@ -218,6 +226,35 @@ function App() {
218226
} catch {}
219227
});
220228

229+
// Repo status transitions (active ↔ missing ↔ removed) — backend
230+
// emits one event per row that changed. Patch allRepos in place
231+
// so the RepoChip row greys out / restores / drops without a
232+
// full reload.
233+
const { listen } = await import("@tauri-apps/api/event");
234+
unStatus = await listen<{ canonicalPath: string; status: string }>(
235+
"timeline://repo-status",
236+
(e) => {
237+
if (!mounted) return;
238+
const { canonicalPath, status } = e.payload;
239+
if (status === "removed") {
240+
setAllRepos((prev) => prev.filter((r) => r.path !== canonicalPath));
241+
setDiscoveredCount((prev) =>
242+
prev != null ? Math.max(0, prev - 1) : prev,
243+
);
244+
return;
245+
}
246+
if (status === "active" || status === "missing") {
247+
setAllRepos((prev) =>
248+
prev.map((r) =>
249+
r.path === canonicalPath
250+
? { ...r, status: status as "active" | "missing" }
251+
: r,
252+
),
253+
);
254+
}
255+
},
256+
);
257+
221258
unFill = await onTimelineRepoFill((p) => {
222259
if (!mounted) return;
223260
// Only merge into the All-repos timeline; ignore while in single mode.
@@ -243,6 +280,7 @@ function App() {
243280
unProgress?.();
244281
unDiscovered?.();
245282
unFill?.();
283+
unStatus?.();
246284
};
247285
// eslint-disable-next-line react-hooks/exhaustive-deps
248286
}, []);
@@ -529,6 +567,19 @@ function App() {
529567
selectedPath={selectedRepoPath}
530568
onSelect={setSelectedRepoPath}
531569
onTogglePin={togglePin}
570+
onHide={(path) => {
571+
// Optimistic: drop from local list immediately; backend
572+
// will tombstone so it stays gone across restarts.
573+
setAllRepos((prev) => prev.filter((r) => r.path !== path));
574+
setDiscoveredCount((prev) =>
575+
prev != null ? Math.max(0, prev - 1) : prev,
576+
);
577+
void hideRepo(path).catch(() => {
578+
// If the backend rejects (race / already gone), fall
579+
// back to re-fetching the list so UI matches truth.
580+
void listRepos().then((r) => setAllRepos(r));
581+
});
582+
}}
532583
totalRepoCount={repoCount}
533584
/>
534585
{singleMode && (

0 commit comments

Comments
 (0)