Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 97 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js)
| `DbGate` | Serializes every use of the shared `output.db` `SqliteConnection`. That one connection is handed to `SearchService` *and* to every `OutputIndexer`, and it is not thread-safe — concurrent create/dispose corrupts its internal command list. Acquire as `using var _ = await DbGate.AcquireAsync();` at the top of anything touching it. See issue #102 |
| `PwshLocator` | Single answer to "pwsh or powershell?", shared by `RunInstance` (run commands) and `PseudoTerminal.BuildCmdLine` (session wrapper) so they can't disagree. Resolves via `where.exe`. An ordinary executable is accepted from metadata alone (no spawn). A **zero-byte reparse point is ambiguous, not bad**: that shape is a Store App Execution Alias, and a *working* Store install of PowerShell 7 looks identical to a stub left by an uninstalled app — so only that case is settled by actually probing execution. Rejecting the shape outright silently downgraded Store-PS7 users to 5.1 on every session launch |
| `ToastHelper` | Tray balloon notifications |
| `OpenWithService` | WPF-free "Open with…": template tokenise/substitute (one placeholder → one argv element), program/value validation, BatBadBut refusal, sanitising state.json/imports, launch. See "Open with…" |
| `OpenWithDetector` | One-time seeding of the Open with list (VS Code family, Visual Studio via vswhere, JetBrains, Zed, Sublime, Notepad++). Disk/registry/spawn — `Task.Run` only |
| `SessionRunner` | Per-session owner of `RunInstance` dictionary (run commands runtime) |
| `RunInstance` | One headless PTY-backed run with ANSI-stripped output buffer |
| `RunCommandTemplatesService` | Detects project type (dotnet/cargo/node/python/make) → seed run-command list |
Expand Down Expand Up @@ -742,13 +744,8 @@ and **Copy branch name**. The UI is in `MainWindow.Git.cs`; git calls are in `Gi
Hidden for SSH sessions — an OSC 9001 branch is display-only, there is no local folder to run
git in.

**"Open in VS Code" is not in this menu** — it is not a git action and must work for a folder
that isn't a repo. It lives in `MainWindow.OpenIn.cs`: a `{ }` button beside 📁 / >_ on the
terminal toolbar, and an item after "Open PowerShell here" in the sidebar right-click menu.
Both appear only when VS Code was found (`code.cmd` on PATH → its `Code.exe`, launched
directly with the folder as one argv element, never through `code.cmd`/`cmd.exe`) and the
session has a local folder. A configurable "Open with…" for other editors and installs is
tracked in #178.
**Opening the folder in an editor is not in this menu** — it is not a git action and must work
for a folder that isn't a repo. See "Open with…" below.

**Deliberately read-mostly: no commit, push or merge.** A Claude session is usually editing
that same working tree, and a one-click history write from outside its conversation is both a
Expand Down Expand Up @@ -875,6 +872,99 @@ fails 2. The full flow was also run end to end in the app with a terminal open i
worktree: session closed, folder deleted, merged branch deleted. `GitServiceThreadingTests`
now also find the repo root from a worktree checkout, where `.git` is a file.

## Open with… (editors and IDEs)

`{ }` ▼ on the terminal toolbar (beside 📁 / >_) and **Open with ▸** in the sidebar right-click
menu open the session's folder in an editor from a user-owned list (#178). The button opens the
default; ▼ lists every entry that has a template for the session's kind, plus **Edit…** (also
right-click on `{ }`, and Settings → Open with). UI in `MainWindow.OpenIn.cs` and
`Views/OpenWithDialog`; every rule is in the WPF-free `Services/OpenWithService` (tested in
`OpenWithServiceTests` / `OpenWithHardeningTests` / `OpenWithDetectorTests`).

**Model.** `AppSettings.OpenWith: List<OpenWithEntry> { Label, Executable, LocalArgs, WslArgs,
SshArgs, IsDefault }`. A blank template hides the entry for that kind; a non-blank one must use
at least one placeholder (an entry opens the session's location — a template of only literal
arguments would be a fixed command line behind a friendly label). Placeholders: local
`{folder}`; WSL `{folder}` (the `\\wsl$` mirror), `{distro}`, `{linuxPath}` (a blank or `~`
Linux folder is resolved to `$HOME` at click time); SSH `{user}`, `{host}`, `{userAtHost}`,
`{port}`, `{remoteFolder}`. Any other `{name}` is an error, not literal text. `{name:uri}`
percent-encodes the value for splicing into a URI (path placeholders keep `/`; `@` in
`{userAtHost:uri}` becomes `%40` so it can't read as URI user-info).

**Seeded once, then the user's.** `OpenWithDetector` runs on each launch whose state has
`OpenWithSeeded == false` (every state.json from before #178), and the flag is set only when it
found something — so a machine with nothing installed keeps the old zero-config behaviour:
install VS Code later and `{ }` appears on the next launch. Once seeded, deleting every entry
sticks. It finds VS Code stable/Insiders, Cursor, Windsurf and VSCodium (PATH shim → exe, App
Paths, `%LOCALAPPDATA%\Programs`, Program Files, Scoop; all with
`--folder-uri vscode-remote://wsl+{distro:uri}{linuxPath:uri}` and
`--folder-uri vscode-remote://ssh-remote+{userAtHost:uri}{remoteFolder:uri}`), Visual Studio via
`vswhere` (`devenv.exe {folder}`, local only), JetBrains IDEs (standalone, Toolbox 1.x `apps\…`
and 2.x `Programs\…` — the `*64.exe`, never the Toolbox `.cmd` scripts), Zed, Sublime Text and
Notepad++. **It is disk, registry and a vswhere spawn: always `Task.Run`.** The PATH `code.cmd`
lookup comes first, so the zero-config default is what it was before #178. "Detect installed
editors" in the editor re-runs it and adds only new programs.

**Launch rules — all load-bearing:**

- **Split, then substitute.** The template is tokenised first (whitespace; `"…"` groups), and
placeholders are substituted inside their own token, so a value can never become a second
argument. The process is `UseShellExecute = false` + `ArgumentList` — no shell, no
interpolated command line. `OpenWithHardeningTests.ArgumentList_RealProcess_*` proves it
against what .NET really sends: it spawns a child (Windows PowerShell `-File`, echoing
`[Environment]::CommandLine`) with the same `ArgumentList` and splits the raw command line
with `CommandLineToArgvW`. The other round-trip tests use `ShellSession.QuoteForCmd`, which
is the *display* form (preview, tooltips) — the same MSVCRT rules, not the same code.
- **No argument may look like a switch.** A value that *starts* an argv element may not begin
with `-` or `/` (`-` is refused anywhere in a value). The `/` rule is not theoretical —
**verified against Code.exe 1.143.1 on this machine**: launched with an isolated
`--user-data-dir` and the argument `/js-flags=--stack-size=987`, the renderer process came up
with `--js-flags=--stack-size=987`, i.e. Chromium parsed the slash form as a switch and
propagated it (the `--` control behaved identically; `/force-device-scale-factor=` did too).
Every Electron editor inherits this. That is why the seeded VS Code remote forms are a single
`--folder-uri vscode-remote://…` token and not `--remote wsl+X <linux path>`: a bare Linux
path is exactly a `/`-led argument.
- **Values are untrusted** (state.json, imports): control characters are refused; host / user /
distro may not contain whitespace; a local `{folder}` must be an existing directory
(`FolderOpener.Target`, which also normalises forward slashes). A token that is only an empty
placeholder is dropped; an empty value inside a larger token (an SSH session with no remote
folder, in the `--folder-uri` form) is a clear error rather than a different target.
- **Validate before side effects.** `PrepareAsync` checks the program, template and values
first, then the program on disk, and only then probes WSL `$HOME` — never spawn `wsl.exe` for
a command that was going to be refused.
- **Never a batch file if it can be avoided.** `.cmd`/`.bat` run through `cmd.exe`, which
re-parses arguments — `%` expands even inside quotes, `&` chains a command (BatBadBut,
CVE-2024-24576). Detection and Browse… resolve shims to the real exe (`ResolveShim` — a known
VS Code-family layout, or exactly one quoted `.exe` in the script; several is ambiguous and
refused). If the user insists on a batch file, every argument containing a cmd metacharacter
(`%!&|<>^"(),;=`) is **refused, never escaped**.
- **Programs.** Pure check (`ValidateExecutable`): an absolute local `.exe` (no relative, UNC
or device path), **nothing under `%SystemRoot%`** (no editor lives there, and much there runs
commands from its arguments — `ssh -oProxyCommand`, `pcalua -a`, `wmic process call create`,
`MSBuild`, `hh`, `mmc`, `explorer <url>`), and not a known shell/script host by name. A name
check alone is bypassable, so `ValidateExecutableOnDisk` (import, launch, editor — always off
the UI thread) re-checks the **real** path via `GetFinalPathNameByHandle` (8.3 names like
`POWERS~1.EXE`, junctions, symlinks, subst), refuses network drives (final path UNC, or
`DriveType.Network`), and checks the PE `OriginalFilename`/`InternalName`, so a renamed copy of
`powershell.exe` is caught. None of that recognises an arbitrary renamed program — which is
why imports are confirmed by the user.

**Import trust.** An imported backup controls both the program and the literal template, so
filtering can only narrow it. `StateService.Normalize` sanitises the list on every load
(`OpenWithService.Sanitize`, pure: drops bad programs, clears bad templates, one default, fresh
ids); `ImportExportService` re-sanitises with `SanitizeImported` (on-disk checks, batch files
disallowed). Then **`Import_Click` never adopts the list silently**: if it differs from the
current one, a dialog spells out every entry's program and argument template per kind
(`DescribeForReview`) and asks, defaulting to **No — keep the current list**. Imported entries
therefore can't become the default without that confirmation. In the app, every menu item shows
the program's file name in its header and the fully resolved command for that session as its
tooltip (`OpenWithService.Describe`), as does `{ }`, so a disguised label can't hide what runs.
Importing a pre-#178 backup (`OpenWithSeeded` false) keeps the current list without asking.

**Toolbar state.** Per-pane controls live in `_openWithControls`, re-evaluated on the session
VM's `WorkingFolder`/`DisplayName` changes (edit session, a WSL `$HOME` landing), and removed —
with their `PropertyChanged` handler — at every teardown site that drops `_runControls`.

## Opening a folder in Explorer

"Open in Explorer" (toolbar 📁, sidebar menu, note search results) goes through
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi
- **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators
- **Git status** — shows branch and dirty state in the sidebar per session
- **Git tools on the terminal toolbar** — a chip like `⎇ main ● ↑2 ↓5` (branch, uncommitted changes, ahead/behind) on every repo session; its menu does Fetch, fast-forward-only Pull, Status, and Switch branch (worktree-aware). Deliberately read-mostly: no commit, push or merge, and nothing is ever typed into the terminal
- **Open in VS Code / Explorer / PowerShell** — `{ }`, 📁 and `>_` on the terminal toolbar (and in the sidebar right-click menu) open the session's folder; VS Code appears when it is installed with `code` on PATH
- **Open with… / Explorer / PowerShell** — `{ }` ▼, 📁 and `>_` on the terminal toolbar (and in the sidebar right-click menu) open the session's folder. "Open with" is a configurable list of editors and IDEs — VS Code (incl. Insiders), Cursor, Windsurf, Visual Studio, JetBrains IDEs, Zed, Sublime Text and Notepad++ are detected on first run — and VS Code-family editors open WSL and SSH sessions through their remotes
- **Session rename** — double-click any session name or click ✏ to rename inline
- **Shell integration** — programs running in a session can push their accent color, git branch / dirty state, and tab title to CSM via OSC 9001 (handy for SSH overlays). See [`docs/shell-integration.md`](docs/shell-integration.md).
- **Auto-resume** — automatically resumes the last Claude Code session when restoring on startup (`--resume <id>`); toggleable in Settings
Expand Down
Loading