From 422542bf1f8fc36d41e838d8b4f9fef376f3a61a Mon Sep 17 00:00:00 2001 From: Sean Kearon Date: Wed, 22 Jul 2026 14:13:47 +0100 Subject: [PATCH 1/2] Redesign the main screen around inline discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Claude Design handoff (design/design_handoff_fido_redesign): - Discovery is the core loop: typing a branch (debounced, or Enter) scans every configured working tree and renders the results inline as selectable cards, each labelled worktree or main clone — no auto-popping dialogs. - Open actions are gated on discovery: the default tool is a hero button (config or CLI --tool, incl. "none" for an equal-weight grid), the rest sit in a 3-column grid, all visible but locked until the branch is found. - Rider and Visual Studio open the chosen solution chip (the Solution box now filters chips); every other tool opens the folder. The Solution/Folder toggle is gone; --folder starts the run on the Folder chip. - Delete moved to the main screen: worktree-only, two-step inline confirm with dirty/orphan warnings, removes the worktree and local branch only. The long-path force-delete recovery is retained. - The gear opens a default-tool popover with a door to full settings; the chooser/decision/delete dialogs and the branch-placement flow are removed. - Handoff palette lands verbatim as the Light theme with a warm-dark derivation for Dark; flight-log lines are colour-coded per kind. Tests: dialog-driven E2E suites rewritten for the phase machine (160 pass); a screenshot walkthrough captures every design state headlessly. Docs and changelog updated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 49 + Docs/Features.md | 395 +++---- Docs/screenshots/README.md | 51 +- README.md | 42 +- src/Models/AppConfig.cs | 52 +- src/Models/DiscoveredTarget.cs | 18 + src/Models/DiscoveryPhase.cs | 20 + src/Models/Editor.cs | 7 + src/Models/OpenDecision.cs | 14 - src/Models/TargetKind.cs | 11 + src/Services/AvaloniaDialogService.cs | 10 - src/Services/ConfigService.cs | 5 +- src/Services/GitService.cs | 15 + src/Services/IDialogService.cs | 24 +- src/Services/OpenerService.cs | 39 + src/Theme/FidoStyles.axaml | 352 +++++-- src/Theme/FidoTheme.axaml | 312 ++++-- src/ViewModels/ChooserViewModel.cs | 63 -- src/ViewModels/DecisionDialogViewModel.cs | 41 - src/ViewModels/DefaultToolChoice.cs | 31 + .../DeleteWorktreeDialogViewModel.cs | 97 -- src/ViewModels/LogLine.cs | 23 +- src/ViewModels/MainWindowViewModel.cs | 446 ++++++-- src/ViewModels/RepoChoice.cs | 32 - src/ViewModels/SettingsViewModel.cs | 37 +- src/ViewModels/SolutionChip.cs | 28 + src/ViewModels/TargetCard.cs | 56 + src/Views/ChooserDialog.axaml | 67 -- src/Views/ChooserDialog.axaml.cs | 82 -- src/Views/DecisionDialog.axaml | 79 -- src/Views/DecisionDialog.axaml.cs | 48 - src/Views/DeleteWorktreeDialog.axaml | 74 -- src/Views/DeleteWorktreeDialog.axaml.cs | 34 - src/Views/MainWindow.axaml | 505 ++++++--- src/Views/MainWindow.axaml.cs | 997 +++++++++--------- src/Views/SettingsDialog.axaml | 22 - src/Views/SettingsDialog.axaml.cs | 28 - .../Fido.Tests/Dialogs/ChooserDialogTests.cs | 202 ---- .../Fido.Tests/Dialogs/DecisionDialogTests.cs | 85 -- .../Dialogs/DeleteWorktreeDialogTests.cs | 206 ---- .../Dialogs/DeletionScreenshotTests.cs | 92 -- .../Fido.Tests/Dialogs/SettingsDialogTests.cs | 12 +- tests/Fido.Tests/E2E/DeleteWorktreeTests.cs | 402 +++---- tests/Fido.Tests/E2E/EditorSelectionTests.cs | 215 ++-- .../Fido.Tests/E2E/MultipleWorktreesTests.cs | 41 +- tests/Fido.Tests/E2E/NewBranchRepoTests.cs | 293 ++--- .../Fido.Tests/E2E/RedesignScreenshotTests.cs | 82 ++ .../Fido.Tests/E2E/RiderNotInstalledTests.cs | 57 +- tests/Fido.Tests/E2E/SolutionChipTests.cs | 133 +++ .../E2E/StartupAndValidationTests.cs | 461 ++++---- tests/Fido.Tests/E2E/TwoClonesTests.cs | 87 +- tests/Fido.Tests/E2E/TwoReposTests.cs | 111 +- .../Infrastructure/FakeDialogService.cs | 47 +- .../Infrastructure/UiTestExtensions.cs | 27 +- tests/Fido.Tests/README.md | 6 +- tests/Fido.Tests/SmokeTest.cs | 29 +- .../ViewModels/SettingsViewModelTests.cs | 35 +- 57 files changed, 3333 insertions(+), 3496 deletions(-) create mode 100644 src/Models/DiscoveredTarget.cs create mode 100644 src/Models/DiscoveryPhase.cs delete mode 100644 src/Models/OpenDecision.cs create mode 100644 src/Models/TargetKind.cs delete mode 100644 src/ViewModels/ChooserViewModel.cs delete mode 100644 src/ViewModels/DecisionDialogViewModel.cs create mode 100644 src/ViewModels/DefaultToolChoice.cs delete mode 100644 src/ViewModels/DeleteWorktreeDialogViewModel.cs delete mode 100644 src/ViewModels/RepoChoice.cs create mode 100644 src/ViewModels/SolutionChip.cs create mode 100644 src/ViewModels/TargetCard.cs delete mode 100644 src/Views/ChooserDialog.axaml delete mode 100644 src/Views/ChooserDialog.axaml.cs delete mode 100644 src/Views/DecisionDialog.axaml delete mode 100644 src/Views/DecisionDialog.axaml.cs delete mode 100644 src/Views/DeleteWorktreeDialog.axaml delete mode 100644 src/Views/DeleteWorktreeDialog.axaml.cs delete mode 100644 tests/Fido.Tests/Dialogs/ChooserDialogTests.cs delete mode 100644 tests/Fido.Tests/Dialogs/DecisionDialogTests.cs delete mode 100644 tests/Fido.Tests/Dialogs/DeleteWorktreeDialogTests.cs delete mode 100644 tests/Fido.Tests/Dialogs/DeletionScreenshotTests.cs create mode 100644 tests/Fido.Tests/E2E/RedesignScreenshotTests.cs create mode 100644 tests/Fido.Tests/E2E/SolutionChipTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e7c06..533c855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **The main screen was redesigned around inline discovery** (per the Claude Design handoff in + `design/design_handoff_fido_redesign`) — one window, one screen, no auto-popping dialogs: + - **Discovery is the core loop.** Typing a branch name (debounced, or Enter to fire immediately) + scans every configured working tree and clone for where that branch is checked out. Results render + **inline as selectable cards** — each labelled **worktree** or **main clone** (worktrees first, the + first auto-selected) with its repo, solution count, and last-updated age. The separate "Open from + branch folder" window and the multi-checkout chooser dialog are gone. + - **Open actions stay locked until discovery succeeds.** The hero button and tool grid are always on + show but enabled only when the branch was **found** — with a one-line reason (idle / scanning / + not found) in their place until then. Nothing opens before discovery resolves. + - **The default tool is a hero button, not a hard-coded Rider.** Whatever config or the CLI sets + drives the full-width amber **Open in <tool>** button; the remaining tools sit in a + three-column grid with their `Ctrl+1…9` accelerators. Choosing **no default** (new ⚙ popover + option, or `--tool none`) renders every tool at equal weight. + - **The Solution/Folder toggle is gone; behaviour is inferred per tool.** Rider and Visual Studio + open the **chosen solution chip** (`*.sln`/`*.slnx`/`*.slnf` detected per target; the Solution box + now **filters** the chips); WebStorm, VS Code, Zed, Console, and File Explorer always open the + folder. A **Folder** chip opens the working tree itself (`--folder` starts the run on it). + - **Delete moved to the main screen.** "Delete worktree & branch" sits under the open actions, + enabled only when the selected target is a **linked worktree** on a non-default branch. Clicking it + swaps the button for an **in-place two-step confirm** that spells out the worktree path and branch + — plus uncommitted-change and orphaned-commit warnings — with Cancel/Delete (Esc backs out; Enter + never deletes). The confirmed delete removes the worktree and its **local** branch; the branch on + `origin` is never touched. The "filename too long" recovery (permanent, Recycle-Bin-bypassing + folder delete) still offers itself when git can't remove the folder. + - **The ⚙ gear opens a default-tool popover** (radio list incl. *No default (equal weight)*, with a + note that a `--tool` launch flag overrides it per run) and an **All settings…** door to the full + settings dialog. + - **CLI:** `fido [tool]`, `--branch/-b`, `--solution/-s` (chip filter), and `--tool/-t` + (`--editor/-e` still accepted) which also takes kind aliases (`webstorm`, `vscode`, `explorer`, …) + and `none`. A CLI-supplied branch **prefills and scans**; naming a tool makes it the run's hero and + **auto-opens only when discovery finds exactly one location** — with several, the choice is always + presented, never auto-popped. An unknown tool id is reported (with the known ids) and never guessed. + - **A warm, cream-and-amber look.** The handoff's palette is now the Light theme verbatim — canvas + `#FBF9F3`, amber `#F4A62A` accents, worktree/main-clone colour coding, red-outline danger styling — + with the Dark theme re-derived in the same warm hues, anchored on the logo tile. The flight log + keeps the FIDO aviation voice, now colour-coded per line kind (accent/ok/warn/muted/plain), and the + window height hugs its content. + +### Removed + +- **The chooser, decision, and delete-worktree dialogs** — inline discovery, the target cards, and the + in-place delete confirm replace all three. The **"place a branch that isn't checked out anywhere"** + flow (main-tree checkout / new worktree via the decision dialog) went with them: a branch checked out + nowhere now lands on a clear **not found** state. The Settings section for **New-branch repos** was + removed accordingly (a configured list is preserved on disk, unused). + ### Added - **Delete a worktree, its branch, and the remote branch — from the branch-folder chooser.** When diff --git a/Docs/Features.md b/Docs/Features.md index 69ae1fd..831b6d3 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -1,9 +1,9 @@ # Fido — Features **Fido** is a small desktop utility that turns a **branch name** into an open Rider -window. You tell it the branch (and optionally a solution name); it works out -*where that branch lives on disk* — an existing checkout, a linked worktree, or a -fresh one — and launches **JetBrains Rider** there. +window. You type the branch; it works out *where that branch lives on disk* — every +linked worktree and main clone currently checked out on it — and launches +**JetBrains Rider** (or any other configured tool) there. The name is a nod to the Apollo **Flight Dynamics Officer (FIDO)**, whose job was to track the spacecraft and compute its trajectory. Fido does the same for your code: @@ -16,168 +16,178 @@ a branch name in, its exact path on disk out. ## Overview -Given a branch and a solution, Fido: +Given a branch, Fido: -1. Finds the git repository (or repositories) on your machine that contain the solution. -2. Works out where the branch should be opened — reusing an existing checkout when one - exists, or letting you switch the main tree / create a worktree when it doesn't. -3. Opens the resolved `.sln`/`.slnx`/`.slnf` (or the repo folder) in your chosen editor — Rider by - default, or WebStorm / VS Code / Visual Studio / Zed / a custom editor. +1. Scans your configured **search roots** for every working tree **currently on that + branch** — linked worktrees and main clones alike. +2. Shows each location **inline on the main screen** as a selectable card, clearly + labelled **worktree** or **main clone**, and lets you choose which to act on. +3. Opens the chosen `.sln`/`.slnx`/`.slnf` (or the folder) in your chosen tool — Rider by + default, or WebStorm / VS Code / Visual Studio / Zed / a terminal / the file + explorer / a custom editor. -Everything is keyboard-friendly, and a live log narrates each step. +Everything happens on one screen, everything is keyboard-friendly, and a live log +narrates each step. --- -## Two ways to use it +## The discovery loop -### 1. Branch + solution name +Type a branch and Fido starts looking. There is no separate search button, no pop-up +chooser, and no second window: -Enter both a **branch** and a **solution name** (a name like `MyApp`, *not* a path). -Fido searches your configured roots for that solution, resolves the branch, and opens it. -This is the full flow, including the ability to **create** a checkout if the branch -isn't on disk yet. +- **Typing** debounces into a scan (about 600 ms after you stop); **Enter** fires one + immediately. +- A status pill tracks the phase: **idle → scanning → found / not found**. +- **Found:** the locations render inline as **target cards** — worktrees listed before + main clones, the first auto-selected. When the branch is checked out in more than + one place, a helper strip says so and you pick the card to act on. +- **Not found:** a warning card says no working tree or clone has the branch — + double-check the name, or fetch the remote first. Fido never switches a checkout or + creates a worktree for you; it opens what already exists. -### 2. Branch only (leave the solution blank) +The **open and delete actions stay locked until discovery succeeds** — nothing opens +before Fido knows where the branch lives, and the locked block states the reason +(`🔒 Enter a branch name to begin discovery`, `🔒 Scanning…`, `🔒 No location found`). -Enter just a **branch**. Fido first scans your roots for a working tree that is **currently -on that branch**, then lists the solution files in that folder (plus an "open the folder" -option) for you to pick. When that folder is a **linked worktree**, the same dialog also offers to -**delete it** — see [Deleting a worktree](#deleting-a-worktree). - -If the branch isn't checked out anywhere, Fido falls back to your **new-branch repos** — the -repositories you tick in Settings. It keeps only those whose refs actually contain the branch -(a local branch **or** an `origin` remote-tracking branch) and offers the same **decision -dialog** as solution mode: **checkout in the main tree** or **create a linked worktree**. If -the branch exists in none of your configured repos, Fido says so and does nothing. +The **Solution** box is optional: it **filters** which detected solutions appear as +chips (see [What gets opened](#what-gets-opened-solution-or-folder)) — it's no longer a +search input or a mode switch. --- ## Feature reference -### Finding the repository +### Finding the branch -- Matches **`.sln`**, **`.slnx`**, and **`.slnf`** (Visual Studio solution filter) files. A full - solution wins over a same-named filter, so a repo's primary target stays the full solution. -- Walks each configured **search root** to a limited depth, skipping noise directories - (`.git`, `node_modules`, `bin`, `obj`, `.vs`, `.idea`, `packages`, `.svn`, `.hg`, and - hidden folders). -- Collapses matches by their **canonical main working tree**, so copies of a solution found - inside worktrees fold back into a single repository entry. +- Walks each configured **search root** to a limited depth looking for git working + trees, skipping noise directories (`.git`, `node_modules`, `bin`, `obj`, `.vs`, + `.idea`, `packages`, `.svn`, `.hg`, and hidden folders). +- Checks each working tree's **current branch** — only trees actually on the typed + branch count. Both **linked worktrees** and a clone's **main tree** qualify. +- Labels every hit: a **worktree** card (git-branch icon, deletable) or a **main + clone** card (home icon, never deletable), with a meta line like + `platform · 2 solutions · updated 3d ago`. +- Detects the **solution files** inside each target — **`.sln`**, **`.slnx`**, and + **`.slnf`** (Visual Studio solution filter) — for the solution chips. -### Cross-clone safety (no duplicate worktrees) +### Cross-clone visibility Git enforces "one worktree per branch" only **within a single clone**. If you have two -clones of the same upstream (e.g. `D:\shine\apps` and `D:\main\apps`), each can independently -check out the same branch — leaving you with duplicate copies on disk. - -Fido prevents this. Before offering to create anything, it scans **every** candidate clone for -a working tree already on the branch (a clone's own main tree counts): - -- **Already checked out somewhere →** it reuses that checkout. No duplicate is created. -- **Checked out in several places →** it asks which one to open. -- **Not checked out anywhere →** only then does it offer to place the branch. +clones of the same upstream (e.g. `D:\shine\apps` and `D:\main\apps`), each can +independently check out the same branch — leaving you with duplicate copies on disk. -### Placing a new branch +Fido makes that visible instead of guessing: **every** location on the branch appears +as its own card, labelled with its kind and owning repo, and **you choose** which one +to open (or delete). Because Fido never creates checkouts itself, it can never add a +duplicate of its own. -When the branch isn't checked out anywhere, Fido shows a **decision dialog** describing the -main tree's current branch, whether the branch exists on `origin`, the resolved start point, -and any outstanding changes. You choose: +### Choosing the target -- **Checkout in main** — switch the main working tree to the branch (creating it if needed). - The dialog warns when the main tree has uncommitted changes that a switch would carry along. -- **Create a worktree** *(default — it's non-destructive)* — add a linked worktree and leave - the main tree untouched. +The selected card is the target for **every** action — the hero button, the tool grid, +the keyboard accelerators, and the delete row. Selecting a different card: -**Start-point resolution** when creating a branch: prefer `origin/` (set up to track), -else the repo's default branch, else the current `HEAD`. +- updates the **context strip** (the `OPEN` row showing the target path and its kind), +- rebuilds the **solution chips** from that target's detected solutions, and +- cancels any pending delete confirmation. -**Worktree location**: a configurable worktree root, otherwise a sibling -`.worktrees/` folder. Existing paths get a numeric suffix so nothing -is clobbered. +Exactly one card is always selected; the first (a worktree, when there is one) is +selected automatically when a scan lands. -### Choosers - -When a choice is needed, Fido shows a keyboard-navigable list with rich, two-line rows: +### What gets opened: solution or folder -- **Pick a clone** (more than one repo matches): each row shows the path plus - *current branch · origin · worktree count* — making it obvious when two rows are clones of - the same upstream. -- **Pick a checkout / folder** (branch is in more than one place): each row shows the path and - the **short HEAD commit**. On GitHub remotes the commit is a **clickable link** to the commit - page (plain text for other remotes) — handy for spotting when two checkouts have diverged. -- **Pick what to open** (branch-only mode): the folder's solutions, plus an "open this folder" - entry. When the folder is a **linked worktree**, a **Delete worktree & branch** button appears too - (see [Deleting a worktree](#deleting-a-worktree)). +- The context strip lists a **chip per detected solution** (`.sln`/`.slnx`/`.slnf`) + in the selected target, plus a **Folder** chip. The first solution is pre-selected; + pick **Folder** to open the working tree itself. +- **Only Rider and Visual Studio consult the chips** — they open the chosen solution + (or the folder, when Folder is chosen or no solutions were found). +- **Every other tool opens the folder** — WebStorm, VS Code, Zed, Console, and File + Explorer have no solution concept and ignore the chips entirely. +- The **Solution** box **filters** the chips by file name (blank = show every solution + found). When the filter hides them all, only the Folder chip remains. + +### Open actions & the default tool + +- The **hero button** is the **default tool** — full-width, marked with a `default` + pill and its `Ctrl+N` accelerator. Every other tool sits in the **3-column grid** + below it, each with its own accelerator. +- **No default set?** There's no hero — all tools render in the equal-weight grid, + with a note: *"No default tool set — every option is equal weight. Pick one, set a + default in ⚙, or pass `--tool` on launch."* +- The **⚙ gear popover** (top-right) sets the default: a radio list of your configured + tools plus **No default (equal weight)**. The choice persists to config immediately. + **All settings…** opens the full Settings dialog from the same popover. +- A CLI `--tool ` overrides the default **for that run only** — picking a radio in + the popover (or editing Settings) takes back over. +- All open actions are visible in every phase but **enabled only when discovery has + found the branch** — the accelerators respect the same gate. ### Deleting a worktree -In branch-only mode, once Fido has located a **linked worktree** on the branch, the **"Open from branch -folder"** chooser adds a **Delete worktree & branch** button alongside the open choices — reachable even when -there's nothing to open (a folder-only editor, or a worktree with no solution file). It's a shortcut for -tidying up a branch you're finished with, in one step: - -- A **confirmation dialog** offers a **checkbox for each present target** — the **worktree**, its **local - branch**, and the **branch on `origin`** — each **ticked by default**. Untick any to keep it, so you can (say) - drop just the remote branch, or remove the worktree while keeping its branches. Because a branch that stays - checked out can't be deleted, **keeping the worktree disables deleting its local branch**. The dialog adds - explicit **data-loss warnings** when the worktree has **uncommitted changes**, or when the branch carries - **commits that exist nowhere else** — unpushed and unmerged work that a force-delete would orphan. Nothing - happens unless you click **Delete** (disabled when nothing is ticked); **Cancel**, **Enter**, and **Esc** all - back out, and the destructive button sits outside the keyboard tab order so it can't be fired by a stray keypress. -- On confirmation Fido carries out **exactly the ticked targets**: it **removes the linked worktree**, - **deletes the local branch**, and — when it exists — **deletes the branch on `origin`**. The git steps run - from the clone's **main working tree**, so the worktree is dropped cleanly; a dirty worktree is - force-removed after the warning. -- Each git step is **retried on transient failures** so a fleeting hiccup doesn't leave a half-tidied branch: - a worktree file still held open by an editor or antivirus scan (common on Windows), a git ref/index `.lock` - left by a racing git process, or a network blip while deleting the branch on `origin`. Fido retries a few - times with a short, backing-off wait — each attempt narrated in the flight log — while **permanent** refusals - (`use --force to delete`, `remote ref does not exist`, "not fully merged") still fail fast on the first try. -- **Long filenames & a force-delete fallback.** Deep worktrees can trip Windows' **260-character `MAX_PATH`** - limit — a `node_modules` tree or generated output whose paths are too long — and a delete then fails with - **`filename too long`** / **`unable to unlink … Filename too long`**, leaving the worktree stuck. Fido guards - against this two ways. First, git's worktree commands run with **long-path support** (`core.longpaths`) so - git's own file operations use the Windows extended-length API and can remove those files. Second, if git - **still** can't delete the folder (a path too long even for that), Fido **offers to delete it straight from - disk**: a recursive removal that **bypasses the Recycle Bin** and uses an extended-length (`\\?\`) path so it - isn't defeated by the same limit. Once the folder is gone Fido **prunes** git's dangling worktree registration - and carries on with the branch deletions. It's an explicit, clearly-labelled confirmation — nothing is - force-deleted unless you choose it, and backing out leaves everything in place. -- If the remote delete fails for good (say you're offline), the completed **local** cleanup stays done and the - failure is reported in the flight log rather than rolled back. - -The button is offered **only for a linked worktree on a non-default branch** — a clone's **main working -tree** can't be worktree-removed, and the default branches (`main`/`master`) are deliberately never offered -for deletion. In those cases the button is hidden and a normal open proceeds. - -### What gets opened: solution or folder - -- **Solution mode:** a radio toggle chooses **Solution** (the `.sln`/`.slnx`/`.slnf`) or **Folder** - (the repo root). If solution mode can't find the file, it falls back to opening the folder. -- **Branch-only mode:** the chooser lists each solution found in the folder plus an - "open the folder" option. -- **Folder-only targets:** some targets only understand a project folder — **WebStorm**, and the - **Console** / **File Explorer** targets that open the folder itself. When one of those is chosen, Fido - always hands over the folder — it ignores the solution toggle and skips the "which solution?" chooser. +Once discovery has found the branch, a **Delete worktree & branch** button sits on the +main screen beneath the tools — no dialog to dig through. It's a shortcut for tidying +up a branch you're finished with: + +- The button is enabled **only when the selected card is a linked worktree on a + non-default branch**. Selecting the **main clone** disables it with a note (*"only + worktrees can be deleted — the main clone stays put."*); the default branches + (`main`/`master`) are deliberately never deletable, even from a worktree (*"default + branches can't be deleted — main/master stay put."*). +- **Two-step inline confirm.** The first click swaps the button for an in-place + confirm strip that spells out exactly what happens — *"Remove the worktree at + `` and delete local branch ``? This can't be undone."* — plus explicit + **data-loss warnings** when the worktree has **uncommitted changes** or the branch + carries **commits that exist nowhere else** (unpushed and unmerged work a delete + would orphan). Nothing happens unless you click **Delete**; **Cancel** or **Esc** + backs out, and the destructive buttons sit outside the keyboard tab order so they + can't be fired by a stray keypress. +- On confirmation Fido **removes the linked worktree** and **deletes the local + branch** — and nothing else. **The branch on `origin` is never touched.** The git + steps run from the clone's **main working tree**, so the worktree is dropped + cleanly; a dirty worktree is force-removed after the warning. +- Each git step is **retried on transient failures** so a fleeting hiccup doesn't + leave a half-tidied branch: a worktree file still held open by an editor or + antivirus scan (common on Windows), or a git ref/index `.lock` left by a racing git + process. Fido retries a few times with a short, backing-off wait — each attempt + narrated in the flight log — while **permanent** refusals still fail fast on the + first try. +- **Long filenames & a force-delete fallback.** Deep worktrees can trip Windows' + **260-character `MAX_PATH`** limit — a `node_modules` tree or generated output whose + paths are too long — and a delete then fails with **`filename too long`** / + **`unable to unlink … Filename too long`**, leaving the worktree stuck. Fido guards + against this two ways. First, git's worktree commands run with **long-path support** + (`core.longpaths`) so git's own file operations use the Windows extended-length API + and can remove those files. Second, if git **still** can't delete the folder (a path + too long even for that), Fido **offers to delete it straight from disk**: a modal, + clearly-labelled confirmation for a recursive removal that **bypasses the Recycle + Bin** and uses an extended-length (`\\?\`) path so it isn't defeated by the same + limit. Once the folder is gone Fido **prunes** git's dangling worktree registration + and carries on with the branch deletion. Nothing is force-deleted unless you choose + it, and backing out leaves everything in place. +- Afterwards the card **drops out of the results** and the next location is selected — + or the screen falls to **not found** when none remain. ### Editors / IDEs Fido can open the resolved target into any of several editors — plus the **Console** and **File Explorer** -targets below. The list is configured in Settings, and one entry is the **default**: +targets below. The list is configured in Settings, and one entry can be the **default**: -- The **default** target is launched by the **Open** button and **Enter**. -- Every other one gets a **secondary button** on the main window and a numbered keyboard - shortcut, **Ctrl+1 … Ctrl+9** (Ctrl+N opens with the Nth entry in the list). +- The **default** tool takes the **hero button**; set it from the **⚙ gear popover** + or the **●** radio in Settings (or leave it unset for the equal-weight grid). +- Every tool — hero included — has a numbered keyboard shortcut, **Ctrl+1 … Ctrl+9** + (Ctrl+N opens with the Nth entry in the configured list). Built-in editor kinds — **Rider**, **WebStorm**, **VS Code**, **Visual Studio**, **Zed** — auto-detect when their path is left blank; a **Custom** editor opens whatever executable/app-bundle path you give it. -**WebStorm** is **folder-only**: it's always handed the repo folder rather than a `.sln`/`.slnx`/`.slnf`. +**WebStorm** is **folder-only**: it's always handed the folder rather than a `.sln`/`.slnx`/`.slnf`. Optional extra command-line arguments can be supplied per editor (passed before the target path). Each entry also carries a **slug** — a short command-line token (built-in defaults: `rider`, `ws`, -`vsc`, `vs`, `zed`, `term`, `files`) — so a specific one can be picked when launching Fido from the command -line (see **Command-line launch**). The slug is editable per entry in Settings; leave it blank to make that -entry un-selectable from the CLI. +`vsc`, `vs`, `zed`, `term`, `files`) — so a specific one can be picked when launching Fido from the +command line (see **Command-line launch**). The slug is editable per entry in Settings, and the +built-in kinds also answer to **well-known aliases** (`webstorm`, `vscode` / `code`, +`visualstudio` / `devenv`, `console` / `terminal`, `explorer` / `fileexplorer` / `finder`…) — only a +**Custom** editor with a blank slug is un-selectable from the CLI. **Auto-detection** for each known kind looks, in order, at an explicit path, then your **`PATH`**, then common install locations: @@ -213,111 +223,116 @@ macOS, and Linux**: else `nautilus` / `dolphin` / `thunar` / `nemo` / `pcmanfm`. The file manager is configurable via the row's **path** too. -Both behave like any other target — a secondary button, a **Ctrl+N** shortcut, and a CLI slug — so +Both behave like any other tool — a grid button, a **Ctrl+N** shortcut, and a CLI slug — so `fido feature/new-ui term` opens a terminal on that branch and `fido feature/new-ui files` opens its folder. -They always hand over the **folder**, ignoring the Solution/Folder toggle and the "which solution?" chooser. +They always hand over the **folder**, ignoring the solution chips. ### Mission-control console -The in-app log narrates each launch like a flight-control "go around the horn" poll: +The in-app **flight log** narrates each scan and launch like a flight-control "go around +the horn" poll: ``` 🚀 Going around the horn… -[✓] Branch resolved: feature/new-ui -[✓] Worktree located: D:\dev\src\worktrees\feature-new-ui -[✓] Solution found: Shine.sln -[✓] Rider located: C:\…\rider64.exe - +Scanning 24 working tree(s) for 'feature/new-ui'… +✓ Found 2 location(s) for 'feature/new-ui'. +✓ Rider located: C:\…\rider64.exe +▸ Opening Shine.sln in Rider Fido? GO! The Eagle has landed... Closing in 7… ``` -The `Closing in N…` line ticks down in place (one line, not a line per second). The countdown also -shows in a **Keep open** bar at the bottom of the window — click it to call off the close and keep +Each fresh scan resets the log and starts the poll again; the `Scanning N working +tree(s)…` line ticks in place as the count comes in. The `Closing in N…` line counts +down in place too (one line, not a line per second), and the countdown also shows in a +**Keep open** bar at the bottom of the window — click it to call off the close and keep Fido up. -When a typed branch isn't checked out anywhere and Fido searches the repos configured for new branches, -it narrates the hunt the same way — `Searching for local branch in `, then `Searching for remote -branch in ` when it queries origin — ticking through the repo names in place on a single line. - -Failures call it straight — `[✗] …` lines and a **No-go** status; a cancelled dialog reads -**Aborted**. +Lines are colour-coded by kind — accent for the mission beats (`🚀`, `🗑`, `Fido? GO!`), +green `✓` for successes, plain `▸` for actions — and failures call it straight: `⚠` +lines for a branch that isn't checked out anywhere, a tool that can't be located, or a +delete that went wrong. ### Keyboard & shortcuts -- The **branch** field is focused on launch; both fields and the Open button have access-key - mnemonics. -- **Enter** triggers **Open in <default editor>**; the inputs disable while a launch is in progress. -- **Ctrl+1 … Ctrl+9** open in the corresponding configured editor (the same editors shown as - secondary buttons), so you can pick a non-default editor without leaving the keyboard. -- **Decision dialog:** `M` / `1` = checkout in main, `W` / `2` = create worktree, - `Enter` = worktree (the default), `Esc` = cancel. -- **Choosers:** `↑` / `↓` move the highlighted row, `Enter` (or the **Open** button, or a - double-click) opens it, `Esc` cancels. The shortcuts work whatever holds focus, and a hint - line along the bottom edge spells them out. +- The **branch** field is focused on launch. Typing debounces into a scan; **Enter** + fires one immediately. +- **Ctrl+Space** in either input opens its **recently-used** suggestions (the **✕** on + a suggestion forgets it). +- **Ctrl+1 … Ctrl+9** open the selected target with the corresponding configured tool + (the same tools shown as buttons), gated — like the buttons — on discovery having + **found** the branch. +- **Esc** backs out of a pending delete confirmation. - **Settings dialog:** `Enter` saves, `Esc` cancels. - **`Alt+Space`** opens the window's native **system menu** (Move, Size, Minimize, Maximize, Close) on any window — the same menu reached from the title-bar icon or a title-bar right-click. -Every dialog follows the same convention — `Enter` triggers the primary action and `Esc` dismisses — -so the keyboard behaves consistently across the app. +The destructive delete buttons sit outside the tab order, so `Enter`/`Tab` can never +land on them by accident. ### Command-line launch -Launch arguments pre-populate the form, and **supplying a branch runs the open flow -automatically** — exactly as if you'd typed it and clicked **Open in Rider**, so any -chooser/decision dialogs still appear when a choice is genuinely needed: +Launch arguments pre-populate the form, and **supplying a branch starts discovery +immediately** — exactly as if you'd typed it. Opening, though, stays deliberate: | Argument | Effect | | --- | --- | -| `` (bare, first) or `--branch` / `-b` `` | Set the branch — **and auto-run the open** | -| `` (bare, second) or `--editor` / `-e` `` | Open with the target whose **slug** matches (e.g. `rider`, `vsc`, `vs`, `zed`, or `term` / `files` for a terminal / file manager) instead of the default | -| `--solution` / `-s` `` | Set the solution name | -| `--folder` | Start in Folder open-mode | - -For example, `fido feature/new-ui -s MyApp` opens that branch's `MyApp` solution and, -by default, closes Fido a few seconds after Rider is launched (see **Close after opening** and -**Close delay** below). - -To pick a non-default target, give its slug as the **second bare argument** — `fido feature/new-ui zed` -opens in Zed, `fido feature/new-ui term` opens a terminal on the branch, `fido feature/new-ui files` opens -its folder — or pass it explicitly with `--editor` / `-e`: `fido -b feature/new-ui -s MyApp -e vs`. -An unrecognised slug stops with a **No-go** that names it (and lists the known slugs) rather than -silently falling back to the default. +| `` (bare, first) or `--branch` / `-b` `` | Set the branch — **discovery runs straight away** | +| `` (bare, second) or `--tool` / `-t` `` (also `--editor` / `-e`) | The named tool becomes **this run's default** (the hero button) — and **auto-opens** when discovery finds **exactly one** location | +| `--solution` / `-s` `` | Pre-fill the **solution filter** | +| `--folder` | Start on the **Folder** chip instead of the first solution | + +A tool `` is a configured **slug** (`rider`, `vsc`, `vs`, `zed`, `term`, `files`…) +or a built-in kind alias (`webstorm`, `vscode`, `visualstudio`, `console`, +`explorer`…). `--tool none` shows the equal-weight grid for this run without touching +your saved default. + +For example, `fido feature/new-ui rider` scans for the branch and — if it's checked +out in exactly one place — opens it in Rider and, by default, closes Fido a few +seconds later (see **Close after opening** and **Close delay** below). The auto-open +is intentionally narrow: + +- **A bare `fido `** (no tool named) scans and presents the results — it + **never auto-opens**. +- **Multiple locations** are never auto-opened either — Fido shows the labelled cards + and lets you choose, rather than guessing between clones. +- **An unrecognised tool id** is reported in the flight log after the scan (listing + the ids that *are* known) and never auto-opens — Fido won't silently fall back to + the default. --- ## Configuration -### Settings (in the app's **Settings** panel) +### Settings (in the app's **Settings** dialog — reach it via ⚙ → **All settings…**) -- **Search roots** — directories to scan for solutions / working trees (one per line). -- **Editors** — the targets Fido can open into. Each row has a name, an optional **slug** (the +- **Search roots** — directories to scan for working trees (one per line). +- **Editors** — the tools Fido can open into. Each row has a name, an optional **slug** (the command-line token that selects it, e.g. `rider`), a **kind** (Rider, WebStorm, VS Code, Visual Studio, Zed, **Console**, **File Explorer**, or Custom), and an optional path (blank = auto-detect for known kinds; required for Custom). For **Console** the path is the **terminal program** and for **File Explorer** the **file manager** (blank = the OS default; a full path or a bare command name like `wt` / `pwsh` both work), so you can point Fido at the terminal you prefer. Tick the - **●** radio to set the default (the Open button / Enter); the rest are reached by **Ctrl+1 … Ctrl+9** - or by their slug on the command line. **Add** appends a new row; **✕** removes one. + **●** radio to set the default (the hero button) — or use the ⚙ gear popover on the main screen, + which offers the same choice plus **No default (equal weight)**. The rest are reached by + **Ctrl+1 … Ctrl+9** or by their slug on the command line. **Add** appends a new row; **✕** removes one. - **Worktree root** — leave blank for the sibling `.worktrees` convention. -- **New-branch repos** — the repositories Fido may place a branch into in **branch-only mode** - when the branch isn't checked out anywhere. Click **Detect** to scan your search roots for git - repositories, then tick the ones to use. +- **Theme** — **System**, **Light**, or **Dark**. - **Close after opening** — when Fido quits after a successful launch: **Command line** *(default — only when started with a branch on the command line)*, **Always** (after every launch, including - the Open button), or **Never** (turns auto-close off). + the on-screen buttons), or **Never** (turns auto-close off). - **Close delay** — seconds Fido counts down before it auto-closes (default **10**; **0** closes immediately). The flight log shows a single line that ticks down in place (`Closing in 10…`, then `9…`, `8…`), and a **Keep open** bar appears at the bottom with the live countdown. Clicking - **Keep open** — or simply starting another open — cancels the close, so it's never a point of no return. + **Keep open** — or simply starting another scan or open — cancels the close, so it's never a point + of no return. ### Defaults - **Search roots:** `%USERPROFILE%\source\repos`, `%USERPROFILE%\src`, `%USERPROFILE%\RiderProjects`, `%USERPROFILE%\Projects`. -- **Default branch names:** `main`, `master`. +- **Default branch names:** `main`, `master` (never offered for deletion). - **Search depth:** 4. - **Close after opening:** command-line launches only, with a **10-second** close delay. @@ -333,14 +348,14 @@ the next save writes to the new location. | Capability | Summary | | --- | --- | -| Input | Branch name (required) + solution name (optional) | -| Branch-only mode | Open from an existing checkout, or place the branch into a configured repo that already has it | -| Cross-clone reuse | Never creates a second worktree for a branch already checked out | -| Placement | Switch main tree **or** create a linked worktree | -| Delete worktree | Remove worktree + local/`origin` branch; retries transient failures; long-path aware with a Recycle-Bin-bypassing force-delete for **`filename too long`** | -| Open target | `.sln` / `.slnx` / `.slnf` solution, or the repo folder | -| Editors | Rider / WebStorm / VS Code / Visual Studio / Zed / Custom — default + Ctrl+1…9, or by CLI slug | +| Input | Branch name (required); the solution box **filters** the detected solution chips | +| Discovery | Debounced scan of the search roots for working trees **currently on the branch**; results inline as cards, worktrees before main clones | +| Multiple locations | Every checkout shown, labelled **worktree** / **main clone** — you choose which to act on | +| Open gate | Open & delete actions unlock only when discovery **finds** the branch | +| Open target | Rider / Visual Studio: the chosen `.sln` / `.slnx` / `.slnf` chip or the folder; every other tool: the folder | +| Delete worktree | Inline two-step confirm; removes the worktree + **local** branch (never the remote); retries transient failures; long-path aware with a Recycle-Bin-bypassing force-delete for **`filename too long`** | +| Tools | Rider / WebStorm / VS Code / Visual Studio / Zed / Custom — hero default + Ctrl+1…9, or by CLI id | | Folder targets | **Console** (`term`) opens a terminal, **File Explorer** (`files`) the OS file manager — Windows / macOS / Linux | | Editor discovery | Explicit path → PATH → standard installs (per kind) | -| Commit links | Short HEAD hash, clickable to the GitHub commit | +| CLI | `fido [tool]` — auto-opens only for an explicitly named tool with exactly one location | | Config | `%APPDATA%\Fido\config.json` (migrates the legacy folder) | diff --git a/Docs/screenshots/README.md b/Docs/screenshots/README.md index b3e8c7d..456d8ea 100644 --- a/Docs/screenshots/README.md +++ b/Docs/screenshots/README.md @@ -8,10 +8,11 @@ screen — *"The Eagle has landed"* — is featured on the [main README](../../R ## Home screen -The mission-control console. Enter a **branch name**, optionally a **solution**, choose **Open -as** Solution or Folder, then launch your default editor with **Open** (Enter) — or any other -editor via its button and **Ctrl+1 … Ctrl+9**. The **Flight log** reports each step as Fido -"goes around the horn". +The mission-control console — one window, one screen. Type a **branch name** and Fido scans your +search roots for every working tree currently on it; the results render **inline** as selectable +cards. The **open actions** below stay locked until discovery finds the branch, then the big amber +button launches your **default tool** (the rest sit in the grid, **Ctrl+1 … Ctrl+9**). The +**Flight log** reports each step as Fido "goes around the horn". @@ -26,11 +27,13 @@ editor via its button and **Ctrl+1 … Ctrl+9**. The **Flight log** reports each --- -## Open from branch folder +## Discovery results -When a branch's folder contains more than one solution — or you'd rather open the repo root — -Fido asks what to open. Move with **↑ / ↓**, confirm with **Enter** or double-click, or back -out with **Esc**. +When a branch is checked out in more than one place, every location gets its own card — labelled +**worktree** or **main clone**, worktrees first — and you choose which to act on. The context strip +shows the selected target with a **chip per detected solution** (plus **Folder**); Rider and Visual +Studio open the chosen chip, every other tool opens the folder. The **Solution** box filters the +chips.
@@ -38,8 +41,8 @@ out with **Esc**. - - + +
Light
Open-from-branch-folder chooser, dark themeOpen-from-branch-folder chooser, light themeInline discovery results with target cards, dark themeInline discovery results with target cards, light theme
@@ -47,8 +50,9 @@ out with **Esc**. ## Delete a worktree -For a **linked worktree** on a non-default branch, the branch-folder chooser adds a **Delete worktree & -branch** button beside the open choices. +When the selected card is a **linked worktree** on a non-default branch, the **Delete worktree & +branch** button beneath the tools is live (selecting the main clone — or a default branch — +disables it with a note saying why). @@ -56,15 +60,15 @@ branch** button beside the open choices. - - + +
Light
Branch-folder chooser with the delete button, dark themeBranch-folder chooser with the delete button, light themeDelete row beneath the open actions, dark themeDelete row beneath the open actions, light theme
-Clicking it opens a confirmation dialog with a **checkbox for each present target** — the worktree, its -local branch, and the branch on `origin` — ticked by default. Untick any to keep it (keeping the worktree -disables deleting its branch), and Fido warns in red about uncommitted changes or commits that exist only on -the branch. +Clicking it swaps the button for an **inline confirm strip** that spells out exactly what happens — +remove the worktree and delete the **local** branch (the branch on `origin` is never touched) — +with warnings when the worktree has uncommitted changes or commits that exist only on that branch. +**Cancel** or **Esc** backs out; only the explicit **Delete** click confirms. @@ -72,8 +76,8 @@ the branch. - - + +
Light
Delete-worktree confirmation dialog, dark themeDelete-worktree confirmation dialog, light themeInline delete confirm strip, dark themeInline delete confirm strip, light theme
@@ -81,9 +85,10 @@ the branch. ## Settings -Configure **search roots**, your **editors** (each with a CLI slug, with the default marked -**●**), the **worktree root**, **new-branch repos**, **theme**, and the **close-after-opening** -behaviour and delay. +The ⚙ gear popover picks the **default tool** (or **No default** for the equal-weight grid); +**All settings…** opens the full dialog to configure **search roots**, your **editors** (each with +a CLI slug, with the default marked **●**), the **worktree root**, **theme**, and the +**close-after-opening** behaviour and delay. diff --git a/README.md b/README.md index 4c385ab..4caba77 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ **Fido** is a launch manager for your IDE — [**JetBrains Rider**](https://www.jetbrains.com/rider/), [**WebStorm**](https://www.jetbrains.com/webstorm/), [**VS Code**](https://code.visualstudio.com/), [**Visual Studio**](https://visualstudio.microsoft.com/), [**Zed**](https://zed.dev/), or any custom editor. -Give it a branch name; it locates the matching Git worktree on disk — switching or -creating one when needed — and opens the solution or repo folder in your editor. Pick a default -(opened with **Enter**); other editors are a **Ctrl+1 … Ctrl+9** away. It can also drop you into a -**terminal** or open the folder in your **file explorer** — on Windows, macOS, and Linux. +Give it a branch name; it scans your repos for every **worktree** and **clone** currently on +that branch, lists them right on the main screen — clearly labelled — and opens your pick's +solution or folder in your editor. Set a **default tool** for the big Open button; every tool is a +**Ctrl+1 … Ctrl+9** away. It can also drop you into a **terminal** or open the folder in your +**file explorer** — on Windows, macOS, and Linux. Finished with a branch? Delete its worktree and +local branch from the same screen, with an inline confirm.

Fido — GO! WebStorm launched; “The Eagle has landed” @@ -20,30 +22,34 @@ creating one when needed — and opens the solution or repo folder in your edito ## Command-line launch -Fido pre-fills its form from the command line, and **giving it a branch runs the open immediately** — -the same flow as typing the branch and pressing **Open** (any chooser/decision dialogs still appear -when a choice is genuinely needed): +Fido pre-fills its form from the command line, and **giving it a branch starts discovery +immediately** — the same flow as typing the branch. Name a **tool** too and Fido auto-opens it, but +only when the branch is checked out in **exactly one** place; multiple locations are presented for +you to choose, never guessed between: ```text -fido feature/new-ui # resolve the branch, open in the default editor -fido feature/new-ui -s MyApp # open MyApp's solution on that branch -fido feature/new-ui zed # open in a specific editor by its slug +fido feature/new-ui # scan for the branch and show every location +fido feature/new-ui rider # …and auto-open in Rider if there's exactly one +fido feature/new-ui -s MyApp # …with the solution chips filtered to MyApp fido feature/new-ui term # open a terminal on that branch (files = file explorer) -fido -b feature/new-ui -s MyApp -e vs # the same, with explicit options +fido -b feature/new-ui -s MyApp -t vs # the same, with explicit options ``` -Each target has a short **slug** (built-in: `rider`, `vsc`, `vs`, `zed`, plus `term` for a terminal and -`files` for the file explorer) that you can pass as the **second argument** — or explicitly with `-e` / -`--editor` — to open in that target instead of the default. Slugs are editable in **Settings**; an unknown -slug stops with a clear **No-go** rather than silently using the default. See -**[Features](Docs/Features.md)** for the full reference. +Each tool has a short **slug** (built-in: `rider`, `vsc`, `vs`, `zed`, plus `term` for a terminal and +`files` for the file explorer) that you can pass as the **second argument** — or explicitly with `-t` / +`--tool` (also `-e` / `--editor`) — and the built-in kinds answer to aliases like `vscode` or +`explorer` too. The named tool becomes the run's default (the hero button); `--tool none` shows the +equal-weight grid instead. Slugs are editable in **Settings**; an unknown id is called out in the +flight log rather than silently using the default. See **[Features](Docs/Features.md)** for the +full reference. --- ## Screenshots -Fido ships with matching **dark** and **light** themes. Browse the full set — home screen, -branch-folder chooser, and settings — in the **[screenshot gallery](Docs/screenshots/)**. +Fido ships with matching **dark** and **light** themes. Browse the full set — the home screen +with its inline discovery results, the delete confirm, and settings — in the +**[screenshot gallery](Docs/screenshots/)**. --- diff --git a/src/Models/AppConfig.cs b/src/Models/AppConfig.cs index 0af9b15..42852d6 100644 --- a/src/Models/AppConfig.cs +++ b/src/Models/AppConfig.cs @@ -30,7 +30,14 @@ public sealed class AppConfig /// public List Editors { get; set; } = new(); - ///

Index into of the default editor (the Open button / Enter). + /// + /// value meaning no default tool is set: the main screen shows no + /// hero button and renders every tool at equal weight in the grid. + /// + public const int NoDefaultEditor = -1; + + /// Index into of the default tool (the hero Open button), or + /// when the user wants an equal-weight grid with no hero. public int DefaultEditorIndex { get; set; } /// @@ -79,24 +86,47 @@ public sealed class AppConfig public int CloseAfterOpenDelaySeconds { get; set; } = DefaultCloseAfterOpenDelaySeconds; /// - /// The currently selected default editor (the Open button / Enter), or null when none are configured. - /// Clamps an out-of-range back to the first editor. + /// The currently selected default tool (the hero button), or null when none are configured or the + /// user chose no default (, meaning an equal-weight tool grid). + /// An out-of-range positive index clamps back into the list rather than silently losing the default. /// public Editor? DefaultEditor => - Editors.Count == 0 ? null : Editors[Math.Clamp(DefaultEditorIndex, 0, Editors.Count - 1)]; + Editors.Count == 0 || DefaultEditorIndex == NoDefaultEditor + ? null + : Editors[Math.Clamp(DefaultEditorIndex, 0, Editors.Count - 1)]; /// - /// Finds the configured editor whose matches - /// (case-insensitive, trimmed), or null when none match. Editors with a blank slug are skipped, so they - /// can't be selected from the command line. Used to honour fido <branch> <slug>. + /// Finds the configured editor matching (case-insensitive, trimmed) by its + /// configured or a well-known alias of its kind (webstorm, + /// vscode, explorer, console…), or null when none match. Display names are + /// deliberately not consulted — a blank-slug custom editor stays un-selectable from the command + /// line. Used to honour fido <branch> <tool> and --tool <id>. /// public Editor? FindEditorBySlug(string? slug) { if (string.IsNullOrWhiteSpace(slug)) return null; - var wanted = slug.Trim(); - return Editors.FirstOrDefault(e => - !string.IsNullOrWhiteSpace(e.Slug) && - string.Equals(e.Slug.Trim(), wanted, StringComparison.OrdinalIgnoreCase)); + var wanted = Normalize(slug); + + var bySlug = Editors.FirstOrDefault(e => + !string.IsNullOrWhiteSpace(e.Slug) && Normalize(e.Slug) == wanted); + if (bySlug is not null) return bySlug; + + return Editors.FirstOrDefault(e => KindAliases(e.Kind).Contains(wanted)); + + // Lower-cased with spaces removed, so "VS Code", "vscode" and "VSCODE" all meet in the middle. + static string Normalize(string s) => s.Trim().Replace(" ", "").ToLowerInvariant(); + + static string[] KindAliases(EditorKind kind) => kind switch + { + EditorKind.Rider => ["rider"], + EditorKind.WebStorm => ["webstorm", "ws"], + EditorKind.VsCode => ["vscode", "code", "vsc"], + EditorKind.VisualStudio => ["visualstudio", "vs", "devenv"], + EditorKind.Zed => ["zed"], + EditorKind.Console => ["console", "terminal", "term"], + EditorKind.FileExplorer => ["explorer", "fileexplorer", "files", "finder"], + _ => [], + }; } /// Builds a config seeded with common dev locations and the built-in editor list. diff --git a/src/Models/DiscoveredTarget.cs b/src/Models/DiscoveredTarget.cs new file mode 100644 index 0000000..d163def --- /dev/null +++ b/src/Models/DiscoveredTarget.cs @@ -0,0 +1,18 @@ +namespace Fido.Models; + +/// +/// One place the requested branch is checked out, found by the main screen's discovery scan: the +/// working-tree folder, whether it's a linked worktree or the clone's main tree, which repo it +/// belongs to, and the solution files (*.sln/*.slnx/*.slnf) inside it. +/// +/// Absolute path of the working tree on the branch. +/// Linked worktree or main clone (worktrees are listed first, and only they can be deleted). +/// Folder name of the clone the target belongs to, e.g. platform. +/// Solution files under the target, depth-limited; consulted only by solution-capable tools. +/// Last write time of the folder — the "updated 3d ago" meta line; null when unreadable. +public sealed record DiscoveredTarget( + string Path, + TargetKind Kind, + string RepoName, + IReadOnlyList Solutions, + DateTime? UpdatedUtc); diff --git a/src/Models/DiscoveryPhase.cs b/src/Models/DiscoveryPhase.cs new file mode 100644 index 0000000..e9d943f --- /dev/null +++ b/src/Models/DiscoveryPhase.cs @@ -0,0 +1,20 @@ +namespace Fido.Models; + +/// +/// Where the main screen's discovery scan is in its lifecycle. Drives the status chip, the results +/// body, the open-actions lock (open is enabled iff ), and delete visibility. +/// +public enum DiscoveryPhase +{ + /// No branch entered; nothing scanned yet. + Idle, + + /// A scan is running; open actions stay locked until it resolves. + Scanning, + + /// The branch is checked out in at least one working tree; open actions unlock. + Found, + + /// No working tree or clone has the branch. + NotFound, +} diff --git a/src/Models/Editor.cs b/src/Models/Editor.cs index 8a8e6d3..d2ea256 100644 --- a/src/Models/Editor.cs +++ b/src/Models/Editor.cs @@ -28,6 +28,13 @@ public sealed class Editor /// public bool OpensFolderOnly => Kind is EditorKind.WebStorm or EditorKind.Console or EditorKind.FileExplorer; + /// + /// True when this tool understands solution files and should be handed the chosen .sln/.slnx + /// when one is selected — Rider and Visual Studio only. Every other tool (including VS Code and Zed, + /// which would open a .sln as a text file) always gets the folder and ignores the solution choice. + /// + public bool OpensSolutions => Kind is EditorKind.Rider or EditorKind.VisualStudio; + /// Explicit path to the executable/app bundle; auto-detected from when null/empty. public string? Path { get; set; } diff --git a/src/Models/OpenDecision.cs b/src/Models/OpenDecision.cs deleted file mode 100644 index ec4b6ae..0000000 --- a/src/Models/OpenDecision.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Fido.Models; - -/// Result of the branch-not-found decision dialog. -public enum OpenDecision -{ - /// Switch the main working tree to the branch (creating it if needed). - Main, - - /// Create a new linked worktree and check out there. - Worktree, - - /// User dismissed the dialog. - Cancel, -} diff --git a/src/Models/TargetKind.cs b/src/Models/TargetKind.cs new file mode 100644 index 0000000..3e345d3 --- /dev/null +++ b/src/Models/TargetKind.cs @@ -0,0 +1,11 @@ +namespace Fido.Models; + +/// What kind of checkout a discovered target is — drives its labelling and whether it can be deleted. +public enum TargetKind +{ + /// A linked worktree (removable with git worktree remove). + Worktree, + + /// A clone's main working tree — it can be opened but never deleted. + MainClone, +} diff --git a/src/Services/AvaloniaDialogService.cs b/src/Services/AvaloniaDialogService.cs index ae9eb94..a2137e9 100644 --- a/src/Services/AvaloniaDialogService.cs +++ b/src/Services/AvaloniaDialogService.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Fido.Models; -using Fido.ViewModels; using Fido.Views; namespace Fido.Services; @@ -12,18 +11,9 @@ public sealed class AvaloniaDialogService : IDialogService public AvaloniaDialogService(Window owner) => _owner = owner; - public Task ShowChooserAsync(string title, string prompt, IReadOnlyList items, string? deleteLabel = null) - => new ChooserDialog(title, prompt, items, deleteLabel).ShowDialog(_owner); - - public Task ConfirmDeleteWorktreeAsync(WorktreeDeletion plan) - => new DeleteWorktreeDialog(plan).ShowDialog(_owner); - public Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete request) => new ForceDeleteDialog(request).ShowDialog(_owner); - public Task ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context) - => new DecisionDialog(repo, branch, context).ShowDialog(_owner); - public Task ShowSettingsAsync(AppConfig config, ConfigService configService) => new SettingsDialog(config, configService).ShowDialog(_owner); } diff --git a/src/Services/ConfigService.cs b/src/Services/ConfigService.cs index 9ac26da..810a938 100644 --- a/src/Services/ConfigService.cs +++ b/src/Services/ConfigService.cs @@ -112,7 +112,10 @@ private static AppConfig Normalize(AppConfig cfg) } cfg.ConfigVersion = AppConfig.CurrentConfigVersion; - cfg.DefaultEditorIndex = Math.Clamp(cfg.DefaultEditorIndex, 0, cfg.Editors.Count - 1); + // NoDefaultEditor (-1) is a deliberate "equal-weight grid, no hero" choice — preserve it; + // anything else clamps back into the editor list. + if (cfg.DefaultEditorIndex != AppConfig.NoDefaultEditor) + cfg.DefaultEditorIndex = Math.Clamp(cfg.DefaultEditorIndex, 0, cfg.Editors.Count - 1); return cfg; } } diff --git a/src/Services/GitService.cs b/src/Services/GitService.cs index 71ce3dc..389db70 100644 --- a/src/Services/GitService.cs +++ b/src/Services/GitService.cs @@ -203,6 +203,21 @@ public async Task IsLinkedWorktreeAsync(string dir, CancellationToken ct = return lines.Length >= 2 && !string.Equals(lines[0], lines[1], StringComparison.Ordinal); } + /// + /// Absolute path of the main working tree of the clone that belongs to — + /// identical to when it already is the main tree. Derived from the shared + /// common git dir (its parent folder), so it stays correct under symlinked search roots. Null on + /// any git error; callers fall back to itself. + /// + public async Task GetMainWorktreePathAsync(string dir, CancellationToken ct = default) + { + var r = await Git(dir, ct, "rev-parse", "--path-format=absolute", "--git-common-dir"); + if (!r.Success) return null; + var common = r.StdOut.Trim(); + if (common.Length == 0) return null; + return System.IO.Path.GetDirectoryName(common); + } + /// /// Counts commits reachable from but from no other ref — no other local branch, /// no remote-tracking branch, no tag. These are the commits that would be orphaned (lost to normal diff --git a/src/Services/IDialogService.cs b/src/Services/IDialogService.cs index 91466d3..df82cf3 100644 --- a/src/Services/IDialogService.cs +++ b/src/Services/IDialogService.cs @@ -1,28 +1,15 @@ using Fido.Models; -using Fido.ViewModels; namespace Fido.Services; /// -/// The modal dialogs the main flow drives, abstracted from so the -/// end-to-end flow can be tested headlessly with a fake that returns scripted choices and records -/// what it was shown. The real dialog windows are exercised directly by the dialog widget tests. +/// The modal dialogs the main flow still drives, abstracted from so the +/// end-to-end flow can be tested headlessly with a fake that returns scripted choices and records what +/// it was shown. Discovery, target choice, and worktree deletion render inline on the main screen now; +/// only settings and the exceptional force-delete recovery remain modal. /// public interface IDialogService { - /// - /// Single-select chooser; returns the chosen index, or null if cancelled. When - /// is supplied the dialog also shows a destructive action button, and a - /// result of means the user chose it. - /// - Task ShowChooserAsync(string title, string prompt, IReadOnlyList items, string? deleteLabel = null); - - /// - /// Confirms the destructive delete of a located worktree. Returns the user's per-target selection (which - /// of the worktree, local branch, and origin branch to delete), or null if they backed out. - /// - Task ConfirmDeleteWorktreeAsync(WorktreeDeletion plan); - /// /// After git worktree remove fails (typically a path too long for the OS), asks whether to /// permanently delete the folder straight from disk — a recursive delete that bypasses the Recycle Bin. @@ -30,9 +17,6 @@ public interface IDialogService /// Task ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete request); - /// Branch-not-checked-out decision; returns the chosen action, or null if dismissed. - Task ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context); - /// Opens the settings dialog (modal). Task ShowSettingsAsync(AppConfig config, ConfigService configService); } diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs index 72e1792..cfe0824 100644 --- a/src/Services/OpenerService.cs +++ b/src/Services/OpenerService.cs @@ -87,6 +87,45 @@ public async Task> FindBranchFoldersAsync(AppConfig public IReadOnlyList FindSolutionsInFolder(string folder, AppConfig config) => _finder.Find([folder], SolutionGlobs, config.SearchDepth); + /// + /// Inline discovery for the main screen: scans the search roots for git working trees currently on + /// and describes each as a — linked worktree + /// or main clone, owning repo name, the solution files inside it, and when it last changed. Worktrees + /// are listed before main clones (they're the likelier target, and only they can be deleted). The + /// blocking directory scans run on the thread pool: this is called from a keystroke-debounced loop, + /// so it must never stall the UI thread the way the older dialog-driven flow could afford to. + /// reports how many working trees are being checked as soon as the + /// enumeration finishes, so the UI can narrate "Scanning N working tree(s)…" mid-scan. + /// + public async Task> DiscoverTargetsAsync( + AppConfig config, string branch, Action? onTreeCount = null, CancellationToken ct = default) + { + var trees = await Task.Run(() => _workingTreeFinder.Find(config.SearchRoots, config.SearchDepth), ct); + onTreeCount?.Invoke(trees.Count); + + var targets = new List(); + foreach (var dir in trees) + { + ct.ThrowIfCancellationRequested(); + if (!string.Equals(await _git.GetCurrentBranchAsync(dir, ct), branch, StringComparison.Ordinal)) + continue; + + var kind = await _git.IsLinkedWorktreeAsync(dir, ct) ? TargetKind.Worktree : TargetKind.MainClone; + var mainPath = await _git.GetMainWorktreePathAsync(dir, ct) ?? dir; + var repoName = Path.GetFileName(Path.TrimEndingDirectorySeparator(mainPath)); + var solutions = await Task.Run(() => FindSolutionsInFolder(dir, config), ct); + + DateTime? updated = null; + try { updated = Directory.GetLastWriteTimeUtc(dir); } + catch { /* advisory meta only — an unreadable timestamp shouldn't hide the target */ } + + targets.Add(new DiscoveredTarget(dir, kind, repoName, solutions, updated)); + } + + // Stable sort: worktrees keep their scan order ahead of the main clones. + return [.. targets.OrderBy(t => t.Kind == TargetKind.MainClone ? 1 : 0)]; + } + /// /// Finds repositories whose tree contains a solution or project matching , /// deduplicated by canonical main working tree (so copies inside worktrees collapse to one). diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml index b5771d0..3a1ef46 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -1,11 +1,12 @@ - + @@ -58,6 +59,21 @@ + + + + - - - - - - - - - - - - + @@ -266,51 +255,280 @@ - - + + + + + + + + + + + + + + + - - + - + + + - - + + + + + + + + + + + + + + + + - + + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/src/Theme/FidoTheme.axaml b/src/Theme/FidoTheme.axaml index 9634702..38d0bcc 100644 --- a/src/Theme/FidoTheme.axaml +++ b/src/Theme/FidoTheme.axaml @@ -1,119 +1,231 @@ - + - - - - - + + + + + - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + + + + + - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ViewModels/ChooserViewModel.cs b/src/ViewModels/ChooserViewModel.cs deleted file mode 100644 index 6199a96..0000000 --- a/src/ViewModels/ChooserViewModel.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; - -namespace Fido.ViewModels; - -/// -/// One selectable row in a ChooserDialog: a bold title, an optional dim subtitle, and an -/// optional commit hash shown as a GitHub link (or plain text when no URL is available). -/// -public sealed class ChooserItem -{ - public ChooserItem(string title, string? subtitle = null, string? commitShort = null, string? commitUrl = null) - { - Title = title; - Subtitle = subtitle ?? ""; - HasSubtitle = !string.IsNullOrEmpty(subtitle); - - CommitShort = commitShort ?? ""; - HasCommit = !string.IsNullOrEmpty(commitShort); - CommitUri = Uri.TryCreate(commitUrl, UriKind.Absolute, out var uri) ? uri : null; - HasCommitLink = HasCommit && CommitUri is not null; - HasCommitTextOnly = HasCommit && CommitUri is null; - } - - public string Title { get; } - public string Subtitle { get; } - public bool HasSubtitle { get; } - - public string CommitShort { get; } - public bool HasCommit { get; } - public Uri? CommitUri { get; } - - /// True when the commit hash should render as a clickable link. - public bool HasCommitLink { get; } - - /// True when there's a hash but no URL, so it renders as plain text. - public bool HasCommitTextOnly { get; } -} - -/// Backing data for the generic single-select chooser dialog. -public sealed class ChooserViewModel -{ - public ChooserViewModel(string prompt, IReadOnlyList items, string? deleteLabel = null) - { - Prompt = prompt; - Items = items; - SelectedIndex = items.Count > 0 ? 0 : -1; - - DeleteButtonText = deleteLabel ?? ""; - CanDelete = !string.IsNullOrEmpty(deleteLabel); - } - - public string Prompt { get; } - public IReadOnlyList Items { get; } - - /// Bound TwoWay to the ListBox; read back after the dialog closes. - public int SelectedIndex { get; set; } - - /// Caption of the optional destructive action button (e.g. "Delete worktree & branch"). - public string DeleteButtonText { get; } - - /// True when a delete action was supplied, so the dialog shows its button. - public bool CanDelete { get; } -} diff --git a/src/ViewModels/DecisionDialogViewModel.cs b/src/ViewModels/DecisionDialogViewModel.cs deleted file mode 100644 index f171fa5..0000000 --- a/src/ViewModels/DecisionDialogViewModel.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Fido.Models; - -namespace Fido.ViewModels; - -/// Read-only display data for the branch-not-found decision dialog. -public sealed class DecisionDialogViewModel -{ - public DecisionDialogViewModel(RepositoryInfo repo, string branch, MainContext context) - { - RepoDisplay = repo.DisplayName; - Branch = branch; - BranchExistsOnRemote = context.BranchExistsOnRemote; - StartPointDescription = context.StartPointDescription; - CurrentBranch = context.CurrentBranch; - ProposedWorktreePath = context.ProposedWorktreePath; - OutstandingChanges = context.OutstandingChanges; - } - - public string RepoDisplay { get; } - public string Branch { get; } - - public bool BranchExistsOnRemote { get; } - public string RemoteText => BranchExistsOnRemote ? "Yes — exists on origin" : "No — not on origin"; - - public string StartPointDescription { get; } - public string CurrentBranch { get; } - public string ProposedWorktreePath { get; } - - public IReadOnlyList OutstandingChanges { get; } - public bool HasOutstandingChanges => OutstandingChanges.Count > 0; - - public string OutstandingSummary => HasOutstandingChanges - ? $"{OutstandingChanges.Count} outstanding change(s) in the main working tree:" - : "Main working tree is clean."; - - public bool ShowSwitchWarning => HasOutstandingChanges; - - public string SwitchWarning => - "⚠ Switching in the main tree carries these changes onto the branch, or may be blocked by conflicts. " + - "Creating a worktree leaves the main tree untouched."; -} diff --git a/src/ViewModels/DefaultToolChoice.cs b/src/ViewModels/DefaultToolChoice.cs new file mode 100644 index 0000000..bcd0cf0 --- /dev/null +++ b/src/ViewModels/DefaultToolChoice.cs @@ -0,0 +1,31 @@ +using Fido.Models; +using Fido.Mvvm; + +namespace Fido.ViewModels; + +/// +/// One radio row in the gear popover's default-tool list: a configured tool, or the trailing +/// "No default (equal weight)" row ( = ). +/// The window persists the choice when flips true. +/// +public sealed class DefaultToolChoice : ObservableObject +{ + public DefaultToolChoice(int index, string name) + { + Index = index; + Name = name; + } + + /// Position in , or . + public int Index { get; } + + public string Name { get; } + + private bool _isSelected; + + public bool IsSelected + { + get => _isSelected; + set => SetField(ref _isSelected, value); + } +} diff --git a/src/ViewModels/DeleteWorktreeDialogViewModel.cs b/src/ViewModels/DeleteWorktreeDialogViewModel.cs deleted file mode 100644 index 940d03e..0000000 --- a/src/ViewModels/DeleteWorktreeDialogViewModel.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Fido.Models; -using Fido.Mvvm; - -namespace Fido.ViewModels; - -/// -/// Backing data for the "delete this worktree?" confirmation dialog. Exposes a ticked-by-default checkbox for -/// each present target — the worktree, its local branch, and the branch on origin — that the user can untick -/// to keep. Deleting the local branch requires removing the worktree first (a checked-out branch can't be -/// deleted), so the local-branch box is disabled and cleared whenever the worktree box is unticked. -/// -public sealed class DeleteWorktreeDialogViewModel : ObservableObject -{ - private bool _deleteWorktree = true; - private bool _deleteLocalBranch = true; - private bool _deleteRemoteBranch; - - public DeleteWorktreeDialogViewModel(WorktreeDeletion plan) - { - WorktreePath = plan.WorktreePath; - Branch = plan.Branch; - RemoteBranchExists = plan.RemoteBranchExists; - OutstandingChanges = plan.OutstandingChanges; - OrphanedCommits = plan.OrphanedCommits; - _deleteRemoteBranch = plan.RemoteBranchExists; // ticked by default when there's a remote branch - } - - public string WorktreePath { get; } - public string Branch { get; } - - public bool RemoteBranchExists { get; } - - /// The origin-branch checkbox is only shown when the branch actually exists on origin. - public bool HasRemoteBranch => RemoteBranchExists; - - /// Dim note shown in place of the origin checkbox when the branch was never pushed. - public bool ShowNoRemoteNote => !RemoteBranchExists; - - public bool DeleteWorktree - { - get => _deleteWorktree; - set - { - if (!SetField(ref _deleteWorktree, value)) return; - // Keep the local-branch box in step: a branch checked out in a worktree we're keeping can't be deleted. - if (!value) DeleteLocalBranch = false; - OnPropertyChanged(nameof(CanDeleteLocalBranch)); - OnPropertyChanged(nameof(ShowLocalBranchCoupledHint)); - OnPropertyChanged(nameof(CanConfirm)); - } - } - - public bool DeleteLocalBranch - { - get => _deleteLocalBranch; - set { if (SetField(ref _deleteLocalBranch, value)) OnPropertyChanged(nameof(CanConfirm)); } - } - - public bool DeleteRemoteBranch - { - get => _deleteRemoteBranch; - set { if (SetField(ref _deleteRemoteBranch, value)) OnPropertyChanged(nameof(CanConfirm)); } - } - - /// The local-branch box is usable only while the worktree is being removed. - public bool CanDeleteLocalBranch => DeleteWorktree; - - /// Shows the "remove the worktree first" hint when keeping the worktree blocks deleting its branch. - public bool ShowLocalBranchCoupledHint => !DeleteWorktree; - - /// Delete is enabled only when at least one present target is selected. - public bool CanConfirm => DeleteWorktree || DeleteLocalBranch || (DeleteRemoteBranch && HasRemoteBranch); - - /// The user's selection, with the origin flag forced off when there's no remote branch to delete. - public WorktreeDeletionChoice ToChoice() => - new(DeleteWorktree, DeleteLocalBranch, DeleteRemoteBranch && HasRemoteBranch); - - public IReadOnlyList OutstandingChanges { get; } - public bool HasOutstandingChanges => OutstandingChanges.Count > 0; - - /// Red warning shown when the worktree is dirty — a forced removal loses these changes. - public string OutstandingSummary => HasOutstandingChanges - ? $"⚠ {OutstandingChanges.Count} uncommitted change(s) in this worktree will be lost." - : ""; - - public int OrphanedCommits { get; } - public bool HasOrphanedCommits => OrphanedCommits > 0; - - /// - /// Red warning shown when the branch carries commits that exist nowhere else — force-deleting the branch - /// (git branch -D) would orphan them even though the working tree is clean and "not on origin" - /// alone wouldn't signal the loss. - /// - public string OrphanedSummary => HasOrphanedCommits - ? $"⚠ {OrphanedCommits} commit(s) exist only on '{Branch}' — not on origin or any other branch. Deleting the branch loses them." - : ""; -} diff --git a/src/ViewModels/LogLine.cs b/src/ViewModels/LogLine.cs index 5362b99..02d4ace 100644 --- a/src/ViewModels/LogLine.cs +++ b/src/ViewModels/LogLine.cs @@ -2,7 +2,12 @@ namespace Fido.ViewModels; -public enum LogLevel { Muted, Accent, Secondary, Error } +/// +/// Flight-log line kinds from the redesign handoff: for mission-control voice +/// (🚀 / Fido? GO!), for confirmations (✓), for failures (⚠/✗), +/// for action lines (▸ Opening …), and for everything else. +/// +public enum LogLevel { Muted, Accent, Ok, Warn, Plain } /// One color-coded line in the flight log. public sealed class LogLine @@ -18,17 +23,21 @@ public LogLine(string text, LogLevel level) public bool IsMuted => Level == LogLevel.Muted; public bool IsAccent => Level == LogLevel.Accent; - public bool IsSecondary => Level == LogLevel.Secondary; - public bool IsError => Level == LogLevel.Error; + public bool IsOk => Level == LogLevel.Ok; + public bool IsWarn => Level == LogLevel.Warn; + public bool IsPlain => Level == LogLevel.Plain; - /// Picks a level from the line's marker prefix so callers can keep logging plain strings. + /// Picks a level from the line's marker prefix so callers can keep logging plain strings. + /// Recognises both the redesign's bare markers (✓ ⚠ ▸ 🗑) and the older bracketed ones ([✓]/[✗]/[!]). public static LogLine Infer(string text) { var t = text.TrimStart(); var level = - t.StartsWith("🚀", StringComparison.Ordinal) ? LogLevel.Accent - : t.StartsWith("[✗]", StringComparison.Ordinal) || t.StartsWith("[!]", StringComparison.Ordinal) ? LogLevel.Error - : t.StartsWith("[✓]", StringComparison.Ordinal) ? LogLevel.Secondary + t.StartsWith("🚀", StringComparison.Ordinal) || t.StartsWith("🗑", StringComparison.Ordinal) ? LogLevel.Accent + : t.StartsWith("[✗]", StringComparison.Ordinal) || t.StartsWith("[!]", StringComparison.Ordinal) + || t.StartsWith("⚠", StringComparison.Ordinal) ? LogLevel.Warn + : t.StartsWith("[✓]", StringComparison.Ordinal) || t.StartsWith("✓", StringComparison.Ordinal) ? LogLevel.Ok + : t.StartsWith("▸", StringComparison.Ordinal) ? LogLevel.Plain : t.StartsWith("Fido? GO", StringComparison.Ordinal) || t.StartsWith("Launching", StringComparison.Ordinal) ? LogLevel.Accent : LogLevel.Muted; return new LogLine(text, level); diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index ae00630..f058857 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -1,27 +1,25 @@ using System; using System.Collections.ObjectModel; +using System.IO; using Avalonia.Threading; using Fido.Models; using Fido.Mvvm; namespace Fido.ViewModels; -/// Status call kind for the GO / NO-GO strip. -public enum StatusKind { None, Go, NoGo } - -/// State for the main window: inputs, open mode, busy/status/log. +/// +/// State for the redesigned main window. The screen is a single phase machine +/// (): typing a branch triggers discovery, the results render inline as +/// selectable target cards, and the open/delete actions unlock only when the branch is +/// . The window's code-behind drives the transitions (debounce, +/// scans, launches); this class holds the observable state the XAML binds to. +/// public sealed class MainWindowViewModel : ObservableObject { + // --- Inputs ----------------------------------------------------------------------- + private string _branchName = ""; - private string _solutionName = ""; - private bool _isSolutionMode = true; - private bool _isFolderMode; - private bool _isBusy; - private string _statusText = ""; - private StatusKind _statusKind = StatusKind.None; - private bool _isClosingCountdown; - private string _countdownText = ""; - private bool _liveLineActive; + private string _solutionFilter = ""; public string BranchName { @@ -29,75 +27,365 @@ public string BranchName set => SetField(ref _branchName, value); } - public string SolutionName + /// Filters which detected solutions appear as chips (blank = show every one). + public string SolutionFilter { - get => _solutionName; - set => SetField(ref _solutionName, value); + get => _solutionFilter; + set + { + if (SetField(ref _solutionFilter, value)) + RebuildSolutionChips(); + } } - public bool IsSolutionMode + // --- Phase machine ---------------------------------------------------------------- + + private DiscoveryPhase _phase = DiscoveryPhase.Idle; + + /// The branch the current/most recent scan ran for — message text uses this rather than + /// , which the user may already be retyping. + public string ScannedBranch { get; private set; } = ""; + + public DiscoveryPhase Phase { - get => _isSolutionMode; - set + get => _phase; + private set { - if (SetField(ref _isSolutionMode, value) && value) - { - _isFolderMode = false; - OnPropertyChanged(nameof(IsFolderMode)); - } + if (!SetField(ref _phase, value)) return; + OnPropertyChanged(nameof(IsIdle)); + OnPropertyChanged(nameof(IsScanning)); + OnPropertyChanged(nameof(IsFound)); + OnPropertyChanged(nameof(IsNotFound)); + OnPropertyChanged(nameof(IsLocked)); + OnPropertyChanged(nameof(LockReason)); + OnPropertyChanged(nameof(CanOpen)); + OnPropertyChanged(nameof(CanDelete)); + OnPropertyChanged(nameof(ShowDeleteRow)); + OnPropertyChanged(nameof(ShowDeleteButton)); + OnPropertyChanged(nameof(ShowDeleteDisabledNote)); } } - public bool IsFolderMode + public bool IsIdle => _phase == DiscoveryPhase.Idle; + public bool IsScanning => _phase == DiscoveryPhase.Scanning; + public bool IsFound => _phase == DiscoveryPhase.Found; + public bool IsNotFound => _phase == DiscoveryPhase.NotFound; + + /// The open-actions gate: everything stays locked until discovery succeeds. + public bool IsLocked => _phase != DiscoveryPhase.Found; + + /// The one-line reason shown in place of the open actions while they're locked. + public string LockReason => _phase switch { - get => _isFolderMode; + DiscoveryPhase.Idle => "🔒 Enter a branch name to begin discovery", + DiscoveryPhase.Scanning => "🔒 Scanning… open actions unlock when discovery finishes", + DiscoveryPhase.NotFound => "🔒 No location found — nothing to open", + _ => "", + }; + + private string _scanningBody = ""; + + /// The scanning card's caption, e.g. Scanning 24 working tree(s) for 'x'…. + public string ScanningBody + { + get => _scanningBody; + private set => SetField(ref _scanningBody, value); + } + + public string NotFoundBody => $"No working tree or clone has '{ScannedBranch}'."; + + /// The found-state status pill, e.g. ✓ 2 locations. + public string FoundChipText => + Targets.Count == 1 ? "✓ 1 location" : $"✓ {Targets.Count} locations"; + + // --- Targets ---------------------------------------------------------------------- + + public ObservableCollection Targets { get; } = new(); + + private TargetCard? _selectedTarget; + + public TargetCard? SelectedTarget + { + get => _selectedTarget; set { - if (SetField(ref _isFolderMode, value) && value) + if (!SetField(ref _selectedTarget, value)) return; + CancelDeleteConfirm(); // a different target invalidates a pending confirm + RebuildSolutionChips(); + OnPropertyChanged(nameof(CanOpen)); + OnPropertyChanged(nameof(CanDelete)); + OnPropertyChanged(nameof(SelectedPath)); + OnPropertyChanged(nameof(SelectedKindLabel)); + OnPropertyChanged(nameof(ShowDeleteButton)); + OnPropertyChanged(nameof(ShowDeleteDisabledNote)); + OnPropertyChanged(nameof(DeleteDisabledNote)); + } + } + + /// True when the branch is checked out in more than one place — shows the helper strip. + public bool HasMultipleTargets => Targets.Count > 1; + + public string SelectedPath => _selectedTarget?.Path ?? ""; + public string SelectedKindLabel => _selectedTarget?.KindLabel ?? ""; + + // --- Solution chips --------------------------------------------------------------- + + public ObservableCollection SolutionChips { get; } = new(); + + private SolutionChip? _selectedSolutionChip; + + /// The chosen chip; the Folder chip means "open the working tree itself". Only consulted + /// by solution-capable tools (Rider / Visual Studio). + public SolutionChip? SelectedSolutionChip + { + get => _selectedSolutionChip; + set => SetField(ref _selectedSolutionChip, value); + } + + /// True when the selected target detected any solution files — shows the chip row. + public bool HasSolutionChips => SolutionChips.Count > 1; // more than just the Folder chip + + /// + /// Rebuilds the chip row for the selected target, applying to the + /// file names. Selection resets to the first visible solution (or Folder when none survive). + /// + private void RebuildSolutionChips() + { + SolutionChips.Clear(); + if (_selectedTarget is not null) + { + var filter = SolutionFilter.Trim(); + foreach (var solution in _selectedTarget.Target.Solutions) { - _isSolutionMode = false; - OnPropertyChanged(nameof(IsSolutionMode)); + if (filter.Length == 0 || + Path.GetFileName(solution).Contains(filter, StringComparison.OrdinalIgnoreCase)) + SolutionChips.Add(SolutionChip.ForSolution(solution)); } + SolutionChips.Add(SolutionChip.Folder); + SelectedSolutionChip = SolutionChips.Count > 1 ? SolutionChips[0] : SolutionChip.Folder; + } + else + { + SelectedSolutionChip = null; } + OnPropertyChanged(nameof(HasSolutionChips)); } - public OpenMode CurrentMode => IsFolderMode ? OpenMode.Folder : OpenMode.Solution; + // --- Tools (hero + grid) ---------------------------------------------------------- + + private EditorLaunchOption? _heroTool; - public bool IsBusy + /// The default tool's hero button, or null for the equal-weight grid. + public EditorLaunchOption? HeroTool { - get => _isBusy; - set + get => _heroTool; + private set { - if (SetField(ref _isBusy, value)) - OnPropertyChanged(nameof(NotBusy)); + if (!SetField(ref _heroTool, value)) return; + OnPropertyChanged(nameof(HasHero)); + OnPropertyChanged(nameof(ShowNoDefaultNote)); + OnPropertyChanged(nameof(HeroLabel)); } } - public bool NotBusy => !_isBusy; + public bool HasHero => _heroTool is not null; + public bool ShowNoDefaultNote => _heroTool is null; + public string HeroLabel => _heroTool is null ? "" : $"Open in {_heroTool.Name}"; + + /// The non-default tools, laid out as the 3-column grid (all tools when no default). + public ObservableCollection GridTools { get; } = new(); - public string StatusText + /// + /// Rebuilds the hero/grid from config. is a position into + /// ; (or out of range) means no + /// hero — every tool renders at equal weight. Accelerators stay tied to config order (Ctrl+1…9) + /// regardless of which tool is the hero. + /// + public void SetEditors(IReadOnlyList editors, int defaultIndex) { - get => _statusText; - private set => SetField(ref _statusText, value); + GridTools.Clear(); + EditorLaunchOption? hero = null; + for (var i = 0; i < editors.Count; i++) + { + var gesture = i < 9 ? $"Ctrl+{i + 1}" : ""; + var option = new EditorLaunchOption(i, editors[i].Name, gesture, IsDefault: i == defaultIndex); + if (i == defaultIndex) + hero = option; + else + GridTools.Add(option); + } + HeroTool = hero; } - public StatusKind StatusKind + /// The gear popover's radio rows (each tool + "No default"); populated by the window + /// alongside so both views of the config stay in step. + public ObservableCollection DefaultToolChoices { get; } = new(); + + // --- Delete row ------------------------------------------------------------------- + + private bool _isBranchProtected; + private bool _isConfirmingDelete; + private bool _isDeleting; + private string _deleteConfirmPath = ""; + private string _deleteConfirmBranch = ""; + private string _deleteConfirmWarnings = ""; + + /// True when the scanned branch is a configured default branch (main/master) — those are + /// never deletable, even from a worktree. Set by the orchestrator when a scan completes. + public bool IsBranchProtected { - get => _statusKind; + get => _isBranchProtected; private set { - if (SetField(ref _statusKind, value)) - { - OnPropertyChanged(nameof(HasStatus)); - OnPropertyChanged(nameof(IsStatusGo)); - OnPropertyChanged(nameof(IsStatusNoGo)); - } + if (!SetField(ref _isBranchProtected, value)) return; + OnPropertyChanged(nameof(CanDelete)); + OnPropertyChanged(nameof(ShowDeleteDisabledNote)); + OnPropertyChanged(nameof(DeleteDisabledNote)); + } + } + + public bool CanOpen => IsFound && _selectedTarget is not null; + + public bool CanDelete => CanOpen && _selectedTarget!.IsWorktree && !_isBranchProtected; + + /// The delete row only exists once discovery has found the branch. + public bool ShowDeleteRow => IsFound; + + public bool ShowDeleteButton => ShowDeleteRow && !_isConfirmingDelete; + + public bool ShowDeleteDisabledNote => ShowDeleteButton && !CanDelete; + + public string DeleteDisabledNote => + _isBranchProtected && _selectedTarget?.IsWorktree == true + ? "default branches can't be deleted — main/master stay put." + : "only worktrees can be deleted — the main clone stays put."; + + /// True while the in-place confirm strip replaces the delete button. + public bool IsConfirmingDelete + { + get => _isConfirmingDelete; + private set + { + if (!SetField(ref _isConfirmingDelete, value)) return; + OnPropertyChanged(nameof(ShowDeleteButton)); + OnPropertyChanged(nameof(ShowDeleteDisabledNote)); } } - public bool HasStatus => _statusKind != StatusKind.None; - public bool IsStatusGo => _statusKind == StatusKind.Go; - public bool IsStatusNoGo => _statusKind == StatusKind.NoGo; + /// True while the deletion is actually running — disables the confirm strip's buttons. + public bool IsDeleting + { + get => _isDeleting; + set => SetField(ref _isDeleting, value); + } + + public string DeleteConfirmPath + { + get => _deleteConfirmPath; + private set => SetField(ref _deleteConfirmPath, value); + } + + public string DeleteConfirmBranch + { + get => _deleteConfirmBranch; + private set => SetField(ref _deleteConfirmBranch, value); + } + + /// Safety warnings folded into the confirm strip (uncommitted changes, orphaned commits); + /// empty when the worktree is clean and fully pushed. + public string DeleteConfirmWarnings + { + get => _deleteConfirmWarnings; + private set + { + if (SetField(ref _deleteConfirmWarnings, value)) + OnPropertyChanged(nameof(HasDeleteConfirmWarnings)); + } + } + + public bool HasDeleteConfirmWarnings => _deleteConfirmWarnings.Length > 0; + + /// Swaps the delete button for the confirm strip, spelling out exactly what will happen. + public void ArmDeleteConfirm(WorktreeDeletion plan) + { + DeleteConfirmPath = plan.WorktreePath; + DeleteConfirmBranch = plan.Branch; + + var warnings = new List(); + if (plan.OutstandingChanges.Count > 0) + warnings.Add($"⚠ {plan.OutstandingChanges.Count} uncommitted change(s) will be lost."); + if (plan.OrphanedCommits > 0) + warnings.Add($"⚠ {plan.OrphanedCommits} commit(s) exist only on this branch — not on origin or any other branch."); + DeleteConfirmWarnings = string.Join("\n", warnings); + + IsConfirmingDelete = true; + } + + /// Backs out of a pending confirm (Esc, Cancel, or the selection changing). + public void CancelDeleteConfirm() => IsConfirmingDelete = false; + + // --- Scan lifecycle (driven by the window orchestrator) --------------------------- + + /// A fresh scan: clears the results, resets the log to the mission-control preamble, and + /// locks the open actions behind the scanning phase. + public void BeginScan(string branch) + { + ScannedBranch = branch; + OnPropertyChanged(nameof(NotFoundBody)); + ScanningBody = $"Scanning working trees for '{branch}'…"; + Targets.Clear(); + SelectedTarget = null; + OnPropertyChanged(nameof(HasMultipleTargets)); + Phase = DiscoveryPhase.Scanning; + } + + /// Updates the scanning caption once the tree enumeration reports its count. + public void SetScanTreeCount(int count) => + ScanningBody = $"Scanning {count} working tree(s) for '{ScannedBranch}'…"; + + /// Lands the scan: fills the target cards (worktrees first), selects the first, and + /// resolves the phase to Found or NotFound. + public void CompleteScan(IReadOnlyList targets, bool branchProtected) + { + Targets.Clear(); + foreach (var target in targets) + Targets.Add(new TargetCard(target)); + + IsBranchProtected = branchProtected; + SelectedTarget = Targets.Count > 0 ? Targets[0] : null; + OnPropertyChanged(nameof(FoundChipText)); + OnPropertyChanged(nameof(HasMultipleTargets)); + Phase = targets.Count > 0 ? DiscoveryPhase.Found : DiscoveryPhase.NotFound; + } + + /// Empty branch box: back to the dashed placeholder, nothing scanned. + public void ResetToIdle() + { + ScannedBranch = ""; + Targets.Clear(); + SelectedTarget = null; + OnPropertyChanged(nameof(HasMultipleTargets)); + Phase = DiscoveryPhase.Idle; + } + + /// + /// Drops a just-deleted target from the results, re-selecting the next one — or falling to + /// NotFound when none remain, matching a fresh scan's outcome. + /// + public void RemoveTarget(TargetCard card) + { + Targets.Remove(card); + OnPropertyChanged(nameof(FoundChipText)); + OnPropertyChanged(nameof(HasMultipleTargets)); + SelectedTarget = Targets.Count > 0 ? Targets[0] : null; + if (Targets.Count == 0) + Phase = DiscoveryPhase.NotFound; + } + + // --- Auto-close countdown --------------------------------------------------------- + + private bool _isClosingCountdown; + private string _countdownText = ""; /// True while the post-launch auto-close countdown is running; shows the "keep open" bar. public bool IsClosingCountdown @@ -123,46 +411,7 @@ public void ShowCountdown(int secondsRemaining) /// Hides the close-countdown bar (the countdown was cancelled or has elapsed). public void StopCountdown() => IsClosingCountdown = false; - private string _openButtonText = "Open"; - - /// Caption of the primary Open button — "Open in <default editor>". - public string OpenButtonText - { - get => _openButtonText; - private set => SetField(ref _openButtonText, value); - } - - /// The non-default editors, shown as secondary launch buttons with their shortcut hints. - public ObservableCollection SecondaryEditors { get; } = new(); - - /// True when there's at least one non-default editor to surface a secondary button for. - public bool HasSecondaryEditors => SecondaryEditors.Count > 0; - - /// - /// Rebuilds the editor launch options from config: sets the Open button caption to the default - /// editor and lists the rest as secondary buttons (the first nine get a Ctrl+N gesture). - /// - public void SetEditors(IReadOnlyList editors, int defaultIndex) - { - SecondaryEditors.Clear(); - if (editors.Count == 0) - { - OpenButtonText = "Open (no editor configured)"; - OnPropertyChanged(nameof(HasSecondaryEditors)); - return; - } - - var def = Math.Clamp(defaultIndex, 0, editors.Count - 1); - OpenButtonText = $"Open in {editors[def].Name}"; - - for (var i = 0; i < editors.Count; i++) - { - if (i == def) continue; - var gesture = i < 9 ? $"Ctrl+{i + 1}" : ""; - SecondaryEditors.Add(new EditorLaunchOption(i, editors[i].Name, gesture, IsDefault: false)); - } - OnPropertyChanged(nameof(HasSecondaryEditors)); - } + // --- MRU -------------------------------------------------------------------------- /// Recently used branch names (newest first) shown as the branch box's MRU suggestions. public ObservableCollection RecentBranches { get; } = new(); @@ -183,6 +432,10 @@ static void Replace(ObservableCollection target, IEnumerable sou } } + // --- Flight log ------------------------------------------------------------------- + + private bool _liveLineActive; + /// Color-coded flight-log lines. public ObservableCollection Log { get; } = new(); @@ -251,17 +504,4 @@ void Apply() if (Dispatcher.UIThread.CheckAccess()) Apply(); else Dispatcher.UIThread.Post(Apply); } - - public void SetStatus(string message, StatusKind kind = StatusKind.None) - { - if (Dispatcher.UIThread.CheckAccess()) - { - StatusText = message; - StatusKind = kind; - } - else - { - Dispatcher.UIThread.Post(() => { StatusText = message; StatusKind = kind; }); - } - } } diff --git a/src/ViewModels/RepoChoice.cs b/src/ViewModels/RepoChoice.cs deleted file mode 100644 index 0be4f3a..0000000 --- a/src/ViewModels/RepoChoice.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.IO; -using Fido.Mvvm; - -namespace Fido.ViewModels; - -/// -/// A repository the user can tick in Settings to include in the new-branch offer (the place Fido -/// proposes when a typed branch isn't checked out anywhere). Backs the settings checklist. -/// -public sealed class RepoChoice : ObservableObject -{ - private bool _isEnabled; - - public RepoChoice(string path, bool isEnabled) - { - Path = path; - _isEnabled = isEnabled; - DisplayName = $"{new DirectoryInfo(path).Name} ({path})"; - } - - /// Canonical main-clone path persisted to . - public string Path { get; } - - /// Friendly label for the checklist, e.g. my-app (C:\src\my-app). - public string DisplayName { get; } - - public bool IsEnabled - { - get => _isEnabled; - set => SetField(ref _isEnabled, value); - } -} diff --git a/src/ViewModels/SettingsViewModel.cs b/src/ViewModels/SettingsViewModel.cs index 3fb93e2..3a5a91f 100644 --- a/src/ViewModels/SettingsViewModel.cs +++ b/src/ViewModels/SettingsViewModel.cs @@ -141,9 +141,6 @@ public string CloseAfterOpenDelayText set => SetField(ref _closeAfterOpenDelayText, value); } - /// Repos offered when a typed branch isn't checked out anywhere; ticked ones are persisted. - public ObservableCollection NewBranchRepos { get; } = new(); - public void LoadFrom(AppConfig config) { SearchRootsText = string.Join(Environment.NewLine, config.SearchRoots); @@ -151,7 +148,9 @@ public void LoadFrom(AppConfig config) foreach (var existing in Editors) existing.PropertyChanged -= OnEditorChoiceChanged; Editors.Clear(); - var defaultIndex = config.Editors.Count == 0 + // NoDefaultEditor (-1) means "no hero, equal-weight grid": no row is ticked. Anything else + // clamps into the list so a stale index still lands on a real editor. + var defaultIndex = config.Editors.Count == 0 || config.DefaultEditorIndex == AppConfig.NoDefaultEditor ? -1 : Math.Clamp(config.DefaultEditorIndex, 0, config.Editors.Count - 1); for (var i = 0; i < config.Editors.Count; i++) @@ -163,10 +162,6 @@ public void LoadFrom(AppConfig config) SelectedTheme = config.Theme; CloseAfterOpen = config.CloseAfterOpen; CloseAfterOpenDelayText = config.CloseAfterOpenDelaySeconds.ToString(CultureInfo.InvariantCulture); - - NewBranchRepos.Clear(); - foreach (var path in config.NewBranchRepos) - NewBranchRepos.Add(new RepoChoice(path, isEnabled: true)); } public void ApplyTo(AppConfig config) @@ -175,15 +170,18 @@ public void ApplyTo(AppConfig config) config.WorktreeRoot = string.IsNullOrWhiteSpace(WorktreeRoot) ? null : WorktreeRoot.Trim(); config.Editors = Editors.Select(e => e.ToEditor()).ToList(); - var defaultIndex = -1; + // No ticked row round-trips as NoDefaultEditor: the user may deliberately run with no hero + // (equal-weight tool grid), chosen here or in the gear popover. + var defaultIndex = AppConfig.NoDefaultEditor; for (var i = 0; i < Editors.Count; i++) if (Editors[i].IsDefault) { defaultIndex = i; break; } - config.DefaultEditorIndex = defaultIndex < 0 ? 0 : defaultIndex; + config.DefaultEditorIndex = defaultIndex; config.RiderPath = null; // superseded by Editors; clear the migrated legacy value config.Theme = SelectedTheme; config.CloseAfterOpen = CloseAfterOpen; config.CloseAfterOpenDelaySeconds = ParseDelaySeconds(CloseAfterOpenDelayText); - config.NewBranchRepos = NewBranchRepos.Where(r => r.IsEnabled).Select(r => r.Path).ToList(); + // config.NewBranchRepos is deliberately left untouched: the redesigned main screen no longer + // consumes it, but a saved list survives round-trips in case the placement flow returns. } /// Parses the delay text to whole seconds, clamped to a sane range; unreadable input falls back to the default. @@ -192,23 +190,6 @@ private static int ParseDelaySeconds(string text) => ? Math.Clamp(seconds, 0, AppConfig.MaxCloseAfterOpenDelaySeconds) : AppConfig.DefaultCloseAfterOpenDelaySeconds; - /// The search roots as currently typed (honors unsaved edits) — drives repo detection. - public IReadOnlyList CurrentSearchRoots() => SplitRoots(SearchRootsText); - - /// - /// Folds freshly detected repo paths into the checklist: existing entries keep their tick state, - /// newly discovered repos are added unticked for the user to opt in. - /// - public void MergeDetected(IEnumerable mainPaths) - { - foreach (var path in mainPaths) - { - if (NewBranchRepos.Any(r => string.Equals(r.Path, path, StringComparison.OrdinalIgnoreCase))) - continue; - NewBranchRepos.Add(new RepoChoice(path, isEnabled: false)); - } - } - private static List SplitRoots(string text) => text .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); diff --git a/src/ViewModels/SolutionChip.cs b/src/ViewModels/SolutionChip.cs new file mode 100644 index 0000000..fb861ce --- /dev/null +++ b/src/ViewModels/SolutionChip.cs @@ -0,0 +1,28 @@ +namespace Fido.ViewModels; + +/// +/// One chip in the "Rider / Visual Studio open" row: a detected solution file, or the trailing +/// Folder chip ( null) that opens the working tree itself. The choice +/// only affects solution-capable tools; every other tool opens the folder regardless. +/// +public sealed class SolutionChip +{ + private SolutionChip(string label, string? solutionPath) + { + Label = label; + SolutionPath = solutionPath; + } + + /// Chip caption — the solution file name, or Folder. + public string Label { get; } + + /// Full path of the solution file; null for the Folder chip. + public string? SolutionPath { get; } + + public bool IsFolder => SolutionPath is null; + + public static SolutionChip ForSolution(string path) => + new(System.IO.Path.GetFileName(path), path); + + public static SolutionChip Folder { get; } = new("Folder", null); +} diff --git a/src/ViewModels/TargetCard.cs b/src/ViewModels/TargetCard.cs new file mode 100644 index 0000000..813afb4 --- /dev/null +++ b/src/ViewModels/TargetCard.cs @@ -0,0 +1,56 @@ +using System; +using Fido.Models; + +namespace Fido.ViewModels; + +/// +/// One selectable discovery-result card on the main screen: a place the scanned branch is checked +/// out, wrapped with the display strings the card template binds to. Selection state lives on the +/// list control, not here — the card itself is immutable. +/// +public sealed class TargetCard +{ + public TargetCard(DiscoveredTarget target) + { + Target = target; + Meta = BuildMeta(target); + } + + public DiscoveredTarget Target { get; } + + /// Absolute working-tree path — the card's headline, ellipsised by the template. + public string Path => Target.Path; + + public bool IsWorktree => Target.Kind == TargetKind.Worktree; + public bool IsMainClone => Target.Kind == TargetKind.MainClone; + + /// The trailing kind chip's caption. + public string KindLabel => IsWorktree ? "worktree" : "main clone"; + + /// The meta line, e.g. platform · 2 solutions · updated 3d ago. + public string Meta { get; } + + private static string BuildMeta(DiscoveredTarget t) + { + var solutions = t.Solutions.Count == 1 ? "1 solution" : $"{t.Solutions.Count} solutions"; + var tail = t.Kind == TargetKind.MainClone + ? "currently on this branch" + : $"updated {RelativeAge(t.UpdatedUtc)}"; + return $"{t.RepoName} · {solutions} · {tail}"; + } + + /// Compact "3d ago"-style age; "recently" when the timestamp couldn't be read. + private static string RelativeAge(DateTime? updatedUtc) + { + if (updatedUtc is not { } utc) return "recently"; + var age = DateTime.UtcNow - utc; + return age switch + { + { TotalMinutes: < 1 } => "just now", + { TotalHours: < 1 } => $"{(int)age.TotalMinutes}m ago", + { TotalDays: < 1 } => $"{(int)age.TotalHours}h ago", + { TotalDays: < 60 } => $"{(int)age.TotalDays}d ago", + _ => $"{(int)(age.TotalDays / 30)}mo ago", + }; + } +} diff --git a/src/Views/ChooserDialog.axaml b/src/Views/ChooserDialog.axaml deleted file mode 100644 index 9b07f8e..0000000 --- a/src/Views/ChooserDialog.axaml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + - - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + public async Task> DiscoverTargetsAsync( AppConfig config, string branch, Action? onTreeCount = null, CancellationToken ct = default) @@ -104,28 +107,88 @@ public async Task> DiscoverTargetsAsync( onTreeCount?.Invoke(trees.Count); var targets = new List(); + // Every clone seen during the scan, keyed by its main working tree — the candidate pool for + // the create-a-worktree offer when the branch turns out to be checked out nowhere. + var clones = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var dir in trees) { ct.ThrowIfCancellationRequested(); + var mainPath = await _git.GetMainWorktreePathAsync(dir, ct) ?? dir; + clones.TryAdd(mainPath, mainPath); + if (!string.Equals(await _git.GetCurrentBranchAsync(dir, ct), branch, StringComparison.Ordinal)) continue; var kind = await _git.IsLinkedWorktreeAsync(dir, ct) ? TargetKind.Worktree : TargetKind.MainClone; - var mainPath = await _git.GetMainWorktreePathAsync(dir, ct) ?? dir; - var repoName = Path.GetFileName(Path.TrimEndingDirectorySeparator(mainPath)); var solutions = await Task.Run(() => FindSolutionsInFolder(dir, config), ct); DateTime? updated = null; try { updated = Directory.GetLastWriteTimeUtc(dir); } catch { /* advisory meta only — an unreadable timestamp shouldn't hide the target */ } - targets.Add(new DiscoveredTarget(dir, kind, repoName, solutions, updated)); + targets.Add(new DiscoveredTarget(dir, kind, RepoNameOf(mainPath), mainPath, solutions, updated)); } + if (targets.Count == 0) + targets.AddRange(await FindWorktreeCandidatesAsync(clones.Keys, branch, config, ct)); + // Stable sort: worktrees keep their scan order ahead of the main clones. return [.. targets.OrderBy(t => t.Kind == TargetKind.MainClone ? 1 : 0)]; } + /// + /// The create-a-worktree offers for a branch checked out nowhere: each clone whose refs contain + /// . Cached refs (local branch, refs/remotes/origin) are consulted + /// first — fast and offline, so the keystroke-debounced scan stays cheap. Only when no clone has a + /// cached ref does it fall back to one live ls-remote sweep, narrated per repo, which finds + /// branches pushed to origin that this machine never fetched. The actual fetch happens at open + /// time (/), not here. + /// + private async Task> FindWorktreeCandidatesAsync( + IEnumerable clonePaths, string branch, AppConfig config, CancellationToken ct) + { + var candidates = new List(); + + async Task AddCandidateAsync(string mainPath, bool remoteOnly) + { + var solutions = await Task.Run(() => FindSolutionsInFolder(mainPath, config), ct); + candidates.Add(new DiscoveredTarget( + BuildWorktreePath(new RepositoryInfo(mainPath, ""), branch, config), + TargetKind.NewWorktree, RepoNameOf(mainPath), mainPath, solutions, + UpdatedUtc: null, BranchOnOriginOnly: remoteOnly)); + } + + var misses = new List(); + foreach (var mainPath in clonePaths) + { + ct.ThrowIfCancellationRequested(); + if (await _git.LocalBranchExistsAsync(mainPath, branch, ct)) + await AddCandidateAsync(mainPath, remoteOnly: false); + else if (await _git.RemoteBranchExistsAsync(mainPath, branch, ct)) + await AddCandidateAsync(mainPath, remoteOnly: true); + else + misses.Add(mainPath); + } + + if (candidates.Count > 0) return candidates; + + // Nothing cached anywhere: ask each origin directly, once. A teammate (or a cloud session) + // may have pushed the branch after this machine last fetched. + foreach (var mainPath in misses) + { + ct.ThrowIfCancellationRequested(); + _liveLog($"Checking origin of {RepoNameOf(mainPath)} for '{branch}'…"); + if (await _git.RemoteHasBranchAsync(mainPath, branch, ct)) + await AddCandidateAsync(mainPath, remoteOnly: true); + } + + return candidates; + } + + /// The clone's display name — its main working tree's folder name. + private static string RepoNameOf(string mainPath) => + Path.GetFileName(Path.TrimEndingDirectorySeparator(mainPath)); + /// /// Finds repositories whose tree contains a solution or project matching , /// deduplicated by canonical main working tree (so copies inside worktrees collapse to one). diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml index 3a1ef46..a8affaf 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -382,6 +382,12 @@ + +