Skip to content

Commit db53a6c

Browse files
var-ggclaude
andcommitted
feat(panel): drop/paste add-repo flow + empty state UI (v0.1.1 prep 5/6)
Closes the loop for the "no IDE recents, no git config hints, repo at some arbitrary path" case. Instead of dropping the user on a blank "Scanning…" screen, the panel becomes a first-class drop target. Once at least one repo is known, the same flow lives as a subtle footer hint that doesn't fight for real estate with the timeline. Backend: * discovery_orchestrator::add_repo_explicit — validates with Repository::discover (so dropping a subdirectory of a repo also works), upserts with primary_source='manual', confidence=100, and promotes user_state to 'pinned' so the row survives at the top of paint order. Removes the path from discovery_tombstones if it was previously hidden — the user is explicitly asking for it back. Learns parent + grandparent roots (the same logic the prewarm task uses) so siblings get found on the next scan. Emits `timeline://repo-discovered` so the existing listener appends the row immediately. * commands::explicit_add_repo — Tauri command wrapping the above; validation failure surfaces as "Not a Git working tree" to the frontend instead of a stack trace. Frontend: * Tauri `tauri://drag-drop` listener at window level — accepts every path in a multi-file drop and tries to add each one. Lives next to the existing window pointer-down drag handler. * Document-level `paste` listener with two guards: target must NOT be an input/textarea/contenteditable (so chip search inputs keep working), AND the clipboard text must look like a path (drive letter, leading /, or leading ~). Anything else is ignored — paste of random text never accidentally tries to add a repo. * `addError` state shows the backend's error string ("Not a Git working tree" etc) inline. Auto-clears after 4s so a one-off typo doesn't linger. * `EmptyDropPanel` component for the cold-start state: big dashed drop target, paste hint, scanning indicator only when relevant. * `.panel-footer-hint` strip for the warm state — small "Drop or paste a repo folder to add it" text below the timeline. Surfaces add errors too so the user notices them whether the panel is empty or full. Dark-mode CSS for both the empty panel and the footer hint. Also cleaned up two intentional-dead-code warnings on `GitConfigHint::RootPattern` and `DiscoverySource::{GitConfigIncludeIf, Watcher}` — collected for Phase 2 expansion, not used yet, marked with `#[allow(dead_code)]` and a comment explaining why. Rust tests 34/34, TS type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 882891d commit db53a6c

7 files changed

Lines changed: 310 additions & 3 deletions

File tree

src-tauri/src/commands.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
44
use serde::Serialize;
55
use tauri::{AppHandle, Emitter, Manager};
66

7-
use crate::{cache, discovery, git, settings, watcher};
7+
use crate::{cache, discovery, discovery_orchestrator, git, settings, watcher};
88

99
const MAX_COMMITS_PER_REPO: usize = 10;
1010
const MAX_COMMITS_PER_REPO_NO_WINDOW: usize = 1_000;
@@ -157,6 +157,26 @@ pub async fn current_upstream_status(
157157
.map_err(|e| e.to_string())?
158158
}
159159

160+
/// Add a repo by path (drag-drop / paste). Wraps
161+
/// discovery_orchestrator::add_repo_explicit and turns the
162+
/// validation/Repository::discover error into a frontend-friendly
163+
/// "Not a Git working tree" string. On success the orchestrator has
164+
/// already emitted `timeline://repo-discovered` — we return the same
165+
/// payload so the caller can also handle it synchronously.
166+
#[tauri::command]
167+
pub async fn explicit_add_repo(
168+
app: AppHandle,
169+
path: String,
170+
) -> Result<discovery_orchestrator::DiscoveredRepoPayload, String> {
171+
let app2 = app.clone();
172+
tauri::async_runtime::spawn_blocking(move || {
173+
discovery_orchestrator::add_repo_explicit(&app2, &path)
174+
.map_err(|e| e.to_string())
175+
})
176+
.await
177+
.map_err(|e| e.to_string())?
178+
}
179+
160180
#[tauri::command]
161181
pub async fn changed_files(
162182
repo_path: String,

src-tauri/src/discovery_orchestrator.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,74 @@ pub fn start(app: AppHandle) -> OrchestratorHandle {
116116
OrchestratorHandle { cancel }
117117
}
118118

119+
/// Manually add a repo by path (drag-drop / paste / future file picker).
120+
/// Validates with git2 (will walk up if the user dropped a subdirectory
121+
/// of a repo), upserts with primary_source='manual' + user_state='pinned'
122+
/// + confidence 100 so it stays at the top of the paint order, learns
123+
/// parent roots, and emits `timeline://repo-discovered` so the frontend
124+
/// can append a row immediately.
125+
///
126+
/// Returns the discovered repo payload on success, or an error string
127+
/// the frontend can surface inline (e.g. "Not a Git working tree").
128+
pub fn add_repo_explicit(app: &AppHandle, raw_path: &str) -> Result<DiscoveredRepoPayload> {
129+
let path = PathBuf::from(raw_path.trim());
130+
if path.as_os_str().is_empty() {
131+
return Err(anyhow::anyhow!("empty path"));
132+
}
133+
let validated = validate_repo_candidate(&path)
134+
.ok_or_else(|| anyhow::anyhow!("Not a Git working tree"))?;
135+
136+
let mut conn = cache::open(app)?;
137+
let candidate = Candidate {
138+
path: PathBuf::from(&validated.canonical_path),
139+
source: DiscoverySource::Manual,
140+
confidence: 100,
141+
raw_hint: Some(format!("manual:{raw_path}")),
142+
};
143+
let now = unix_now();
144+
{
145+
let tx = conn.transaction()?;
146+
upsert_discovered_repo(&tx, &validated, &candidate, now)?;
147+
// Manual add overrides any pre-existing 'normal' user_state and
148+
// promotes the repo to 'pinned' so it survives a tombstone-style
149+
// wipe and stays at the top of paint order.
150+
tx.execute(
151+
"UPDATE repos SET user_state = 'pinned' WHERE canonical_path = ?1",
152+
params![&validated.canonical_path],
153+
)?;
154+
record_repo_source(&tx, &validated, &candidate, now)?;
155+
record_path_alias(&tx, &validated, &candidate, now)?;
156+
for learned in learn_roots_from_repo(&validated, DiscoverySource::Manual) {
157+
upsert_discovery_root(&tx, &learned, &validated.canonical_path, now)?;
158+
}
159+
// Manual-added paths shouldn't stay on the tombstone list — the
160+
// user explicitly asked for them back.
161+
tx.execute(
162+
"DELETE FROM discovery_tombstones WHERE canonical_path = ?1",
163+
params![&validated.canonical_path],
164+
)?;
165+
tx.commit()?;
166+
}
167+
168+
let payload = DiscoveredRepoPayload {
169+
path: validated.canonical_path.clone(),
170+
name: validated.name.clone(),
171+
source: DiscoverySource::Manual.as_str(),
172+
confidence: 100,
173+
};
174+
let _ = app.emit("timeline://repo-discovered", &payload);
175+
176+
let _ = append_scan_log(
177+
app,
178+
&format!(
179+
"{} manual_add: path={} name={}",
180+
now, validated.canonical_path, validated.name
181+
),
182+
);
183+
184+
Ok(payload)
185+
}
186+
119187
async fn run_discovery_async(app: AppHandle, cancel: CancellationToken) -> Result<()> {
120188
let app_for_blocking = app.clone();
121189
let cancel_for_blocking = cancel.clone();

src-tauri/src/discovery_sources.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,15 @@ pub enum DiscoverySource {
4242
/// git config `safe.directory`.
4343
GitConfigSafe,
4444
/// git config `includeIf.gitdir:<pattern>` — root hint, not repo.
45+
/// Collected for Phase 2 root expansion.
46+
#[allow(dead_code)]
4547
GitConfigIncludeIf,
4648
/// Filesystem walk (Tier 5).
4749
FsWalk,
48-
/// File watcher saw a `.git` directory appear.
50+
/// File watcher saw a `.git` directory appear. Hooked up in commit 6
51+
/// when the existing watcher promotes its events into orchestrator
52+
/// candidates.
53+
#[allow(dead_code)]
4954
Watcher,
5055
}
5156

@@ -408,6 +413,9 @@ pub fn expand_code_workspace(workspace_file: &Path) -> Vec<Candidate> {
408413
#[derive(Debug, Clone)]
409414
pub enum GitConfigHint {
410415
RepoPath(PathBuf),
416+
/// `includeIf "gitdir:<pat>"` pattern — collected for future
417+
/// Tier 4 expansion (Phase 2). v0.1.1 only acts on RepoPath.
418+
#[allow(dead_code)]
411419
RootPattern(String),
412420
}
413421

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ pub fn run() {
256256
commands::recent_commits,
257257
commands::list_branches,
258258
commands::current_upstream_status,
259+
commands::explicit_add_repo,
259260
commands::repo_commits,
260261
commands::changed_files,
261262
commands::file_diff,

src/App.tsx

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { getCurrentWindow } from "@tauri-apps/api/window";
33
import type { UnlistenFn } from "@tauri-apps/api/event";
44

@@ -10,6 +10,7 @@ import { TimeRangeChip } from "./components/TimeRangeChip";
1010
import {
1111
currentUpstreamStatus,
1212
dismissPanel,
13+
explicitAddRepo,
1314
getPinnedRepos,
1415
listBranches,
1516
listRecentCommitsCached,
@@ -145,6 +146,10 @@ function App() {
145146
"repo" | "time" | "authors" | "branch" | null
146147
>(null);
147148

149+
// Drop/paste add-repo flow: inline feedback only, no modal.
150+
// `addError` clears itself after 4s so a typo'd path doesn't linger.
151+
const [addError, setAddError] = useState<string | null>(null);
152+
148153
const singleMode = selectedRepoPath != null;
149154

150155
// Mirror selectedRepoPath into a ref so the file-watcher listener — set up
@@ -360,6 +365,76 @@ function App() {
360365
};
361366
}, [singleMode, selectedRepoPath, selectedBranches, windowDays]);
362367

368+
// Manual add via drag-drop / paste. Returns whether the add succeeded
369+
// so the paste handler can clear the clipboard string only on success.
370+
// On failure, sets addError to the backend's message ("Not a Git
371+
// working tree" etc) for inline display.
372+
const tryAddPath = useCallback(async (rawPath: string): Promise<boolean> => {
373+
const trimmed = rawPath.trim();
374+
if (!trimmed) return false;
375+
try {
376+
await explicitAddRepo(trimmed);
377+
setAddError(null);
378+
return true;
379+
} catch (err) {
380+
setAddError(
381+
typeof err === "string"
382+
? err
383+
: err instanceof Error
384+
? err.message
385+
: "Failed to add repo",
386+
);
387+
window.setTimeout(() => setAddError(null), 4000);
388+
return false;
389+
}
390+
}, []);
391+
392+
// Tauri drag-drop. Fires on this window's drop zone (the whole panel).
393+
// We listen for the "drop" variant only — "hover"/"cancel" are just
394+
// visual cues we'd opt into later. Multi-file drops add each in turn.
395+
useEffect(() => {
396+
let un: UnlistenFn | undefined;
397+
(async () => {
398+
type DragDrop = { type: string; paths?: string[] };
399+
un = await getCurrentWindow().listen<DragDrop>("tauri://drag-drop", (e) => {
400+
if (e.payload.type !== "drop") return;
401+
const paths = e.payload.paths ?? [];
402+
for (const p of paths) {
403+
void tryAddPath(p);
404+
}
405+
});
406+
})();
407+
return () => un?.();
408+
}, [tryAddPath]);
409+
410+
// Paste: only act when the user has clearly pasted a path (starts with
411+
// a drive letter, slash, or tilde) AND isn't typing into an input/
412+
// textarea/contenteditable. This way chip search inputs keep working
413+
// normally — paste only adds repos when there's no other use for it.
414+
useEffect(() => {
415+
function onPaste(e: ClipboardEvent) {
416+
const target = e.target as HTMLElement | null;
417+
if (target) {
418+
const tag = target.tagName;
419+
if (
420+
tag === "INPUT" ||
421+
tag === "TEXTAREA" ||
422+
target.getAttribute("contenteditable") === "true"
423+
) {
424+
return;
425+
}
426+
}
427+
const text = e.clipboardData?.getData("text/plain")?.trim() ?? "";
428+
if (!text) return;
429+
// Heuristic: looks like a Windows drive, POSIX absolute, or home-rel path.
430+
if (!/^([a-zA-Z]:[\\\/]|\/|~[\\\/])/.test(text)) return;
431+
e.preventDefault();
432+
void tryAddPath(text);
433+
}
434+
window.addEventListener("paste", onPaste);
435+
return () => window.removeEventListener("paste", onPaste);
436+
}, [tryAddPath]);
437+
363438
// Clear fresh-commit markers whenever the panel loses focus (= the user
364439
// has "seen" what was new). They re-populate as new commits arrive.
365440
useEffect(() => {
@@ -503,6 +578,8 @@ function App() {
503578
<section className="panel-body">
504579
{filteredCommits == null ? (
505580
<p className="panel-empty">Loading commits…</p>
581+
) : allRepos.length === 0 && !singleMode ? (
582+
<EmptyDropPanel scanning={scanning} addError={addError} />
506583
) : (
507584
<Timeline
508585
key={singleMode ? `single:${selectedRepoPath}` : "all"}
@@ -513,9 +590,41 @@ function App() {
513590
freshHashes={freshHashes}
514591
/>
515592
)}
593+
{allRepos.length > 0 && (
594+
<div className="panel-footer-hint" title="Drag a repo folder onto the panel, or paste a path">
595+
Drop or paste a repo folder to add it
596+
{addError && <span className="panel-footer-hint-error"> · {addError}</span>}
597+
</div>
598+
)}
516599
</section>
517600
</main>
518601
);
519602
}
520603

604+
interface EmptyDropPanelProps {
605+
scanning: boolean;
606+
addError: string | null;
607+
}
608+
609+
/** First-paint state for a fresh PC where no repos are cached AND the
610+
* background scan hasn't found anything yet (no VS Code recents, no
611+
* git config hints, etc). Shows a big drop target as the *primary* UI
612+
* rather than a blank "Scanning…" screen — the explicit-add path is a
613+
* first-class flow, not a hidden escape hatch. */
614+
function EmptyDropPanel({ scanning, addError }: EmptyDropPanelProps) {
615+
return (
616+
<div className="empty-drop">
617+
<div className="empty-drop-icon" aria-hidden="true">
618+
📂
619+
</div>
620+
<div className="empty-drop-title">Drop a repo folder here</div>
621+
<div className="empty-drop-sub">or paste a path (Ctrl+V / Cmd+V)</div>
622+
{scanning && (
623+
<div className="empty-drop-status">Scanning for repos…</div>
624+
)}
625+
{addError && <div className="empty-drop-error">{addError}</div>}
626+
</div>
627+
);
628+
}
629+
521630
export default App;

src/lib/ipc.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export async function currentUpstreamStatus(
5555
});
5656
}
5757

58+
/** Add a repo by absolute path (drag-drop / paste). Backend validates
59+
* via git2::Repository::discover (so a sub-folder of a repo also works).
60+
* Throws on non-Git paths so the UI can show an inline error. On
61+
* success the orchestrator has already emitted `timeline://repo-
62+
* discovered`, so the listener in App.tsx picks the new row up
63+
* automatically — the resolved Repo is also returned for callers that
64+
* want to show synchronous feedback. */
65+
export async function explicitAddRepo(path: string): Promise<DiscoveredRepo> {
66+
return invoke<DiscoveredRepo>("explicit_add_repo", { path });
67+
}
68+
5869
export async function repoCommits(
5970
repoPath: string,
6071
branches: string[] | null,

0 commit comments

Comments
 (0)