diff --git a/CLAUDE.md b/CLAUDE.md index fa7b0be..ccfe4e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -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 @@ -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 { 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 `: 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 `), 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 diff --git a/README.md b/README.md index d6d81d4..0db6e0c 100644 --- a/README.md +++ b/README.md @@ -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 `); toggleable in Settings diff --git a/src/CodeShellManager/MainWindow.OpenIn.cs b/src/CodeShellManager/MainWindow.OpenIn.cs index d22db84..ed796f2 100644 --- a/src/CodeShellManager/MainWindow.OpenIn.cs +++ b/src/CodeShellManager/MainWindow.OpenIn.cs @@ -1,119 +1,259 @@ using System; -using System.Diagnostics; -using System.IO; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using System.Windows; +using System.Windows.Controls; using System.Windows.Media; +using CodeShellManager.Models; using CodeShellManager.Services; using CodeShellManager.ViewModels; using WpfButton = System.Windows.Controls.Button; using Brushes = System.Windows.Media.Brushes; using Color = System.Windows.Media.Color; using FontFamily = System.Windows.Media.FontFamily; +using Orientation = System.Windows.Controls.Orientation; +using MenuItem = System.Windows.Controls.MenuItem; +using ContextMenu = System.Windows.Controls.ContextMenu; +using Separator = System.Windows.Controls.Separator; namespace CodeShellManager; /// -/// "Open in VS Code" — on the terminal toolbar next to 📁 / >_, and in the sidebar right-click -/// menu next to "Open in Explorer" / "Open PowerShell here". It used to live in the git menu, -/// which was the wrong home: opening a folder in an editor has nothing to do with git, and -/// should work for a folder that isn't a repo. A configurable "Open with…" for other editors -/// (and VS Code installs this lookup misses) is tracked in #178. +/// "Open with…" (issue #178) — the session's folder in an editor / IDE from a user-owned +/// list (). On the terminal toolbar as { } + ▼ beside +/// 📁 / >_ (the ▶/▼ run-button pattern: the button opens the default, the chevron lists every +/// entry for this session's kind), and as "Open with ▸" in the sidebar right-click menu. +/// All template, validation and launch rules live in the WPF-free +/// ; this file is only the UI. /// public partial class MainWindow { - /// - /// Resolved once per process, off the UI thread (it walks PATH and touches the disk). - /// Null result = not found; the button and menu item then simply don't appear. - /// - private static Task? _vsCodeLookup; + /// Per-session toolbar controls, so a list change can repaint every pane. Pruned on refresh. + private readonly Dictionary _openWithControls = new(); - private static Task VsCodeLookup() => _vsCodeLookup ??= Task.Run(FindVsCode); + private bool _openWithSeeding; - /// Only a session with a local folder can be opened — not SSH. - private static bool CanOpenInEditor(SessionViewModel vm) => - !vm.Session.IsRemote && !string.IsNullOrEmpty(vm.WorkingFolder); + /// + /// Seeds by auto-detection, once per profile. Detection + /// is disk + registry I/O and a vswhere spawn, so it runs on the pool; only the hand-back + /// touches the settings, on the UI thread. + /// + private void EnsureOpenWithSeeded() + { + if (_vm.Settings.OpenWithSeeded || _openWithSeeding) return; + _openWithSeeding = true; + _ = Task.Run(OpenWithDetector.Detect).ContinueWith(t => + { + Dispatcher.BeginInvoke(() => + { + _openWithSeeding = false; + // The user may have edited (or imported) the list while detection ran — theirs wins. + if (_vm.Settings.OpenWithSeeded) return; + if (t.IsCompletedSuccessfully && t.Result.Count == 0) + { + // Nothing found: stay unseeded, so installing VS Code later still makes + // { } appear on the next launch — the pre-#178 zero-config behaviour. + OpenWithService.Log("detection found no editors; will try again next launch"); + } + else if (t.IsCompletedSuccessfully) + { + _vm.Settings.OpenWith = t.Result; + _vm.Settings.OpenWithSeeded = true; + OpenWithService.Log($"seeded {t.Result.Count} entr{(t.Result.Count == 1 ? "y" : "ies")}: " + + string.Join(", ", t.Result.Select(e => $"{e.Label} ({e.Executable})"))); + _ = _vm.SaveStateAsync(); + } + else + { + OpenWithService.Log($"detection failed: {t.Exception?.GetBaseException().Message}"); + } + RefreshOpenWithButtons(); + }); + }, TaskScheduler.Default); + } /// - /// The toolbar button. Starts collapsed and appears once the lookup finds VS Code, so a - /// machine without it never shows a button that can't work. + /// The toolbar { } + ▼ pair, in one panel so it docks as a unit. Hidden when no + /// entry applies to this session's kind (or the session has nothing to open). /// - private WpfButton BuildVsCodeButton(SessionViewModel vm) + private StackPanel BuildOpenWithButtons(SessionViewModel vm) { var btn = new WpfButton { Content = "{ }", - ToolTip = "Open folder in VS Code", Background = Brushes.Transparent, BorderThickness = new Thickness(0), Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xad, 0xc8)), FontSize = 11, FontFamily = new FontFamily("Consolas"), Cursor = System.Windows.Input.Cursors.Hand, - Padding = new Thickness(5, 2, 5, 2), + Padding = new Thickness(5, 2, 2, 2), + }; + btn.Click += (_, _) => + { + var entry = OpenWithService.DefaultFor(_vm.Settings.OpenWith, vm.Session.Kind); + if (entry != null) OpenSessionWith(vm, entry); + }; + btn.MouseRightButtonUp += (_, e) => { OpenOpenWithEditor(); e.Handled = true; }; + // Computed when shown, from the live session — so it can't go stale after an edit, + // and it spells out the exact command rather than trusting the entry's label. + btn.ToolTip = ""; + btn.ToolTipOpening += (_, _) => + { + var entry = OpenWithService.DefaultFor(_vm.Settings.OpenWith, vm.Session.Kind); + btn.ToolTip = entry == null + ? "Open with…" + : $"Open folder in {entry.Label}\n{OpenWithService.Describe(entry, OpenWithContext.FromSession(vm.Session))}\n(right-click: edit list)"; + }; + + var chevron = new WpfButton + { + Content = "▼", + ToolTip = "Open with…", + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xad, 0xc8)), + FontSize = 9, + Cursor = System.Windows.Input.Cursors.Hand, + Padding = new Thickness(2, 2, 4, 2), + }; + chevron.Click += (_, _) => ShowOpenWithDropdown(vm, chevron); + + var host = new StackPanel + { + Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 4, 0), - Visibility = Visibility.Collapsed, + VerticalAlignment = VerticalAlignment.Center, }; - btn.Click += (_, _) => OpenInVsCode(vm.WorkingFolder); + host.Children.Add(btn); + host.Children.Add(chevron); - if (CanOpenInEditor(vm)) + // A pane rebuild for the same session replaces the old controls. + RemoveOpenWithControls(vm.Id); + // Kind / folder / host can change under a live pane (Edit session, a WSL $HOME landing), + // which is when the button may need to appear or disappear. + System.ComponentModel.PropertyChangedEventHandler onChanged = (_, e) => { - _ = VsCodeLookup().ContinueWith(t => - { - if (t.IsCompletedSuccessfully && t.Result != null) - Dispatcher.BeginInvoke(() => btn.Visibility = Visibility.Visible); - }, TaskScheduler.Default); + if (string.IsNullOrEmpty(e.PropertyName) + || e.PropertyName is nameof(SessionViewModel.WorkingFolder) or nameof(SessionViewModel.DisplayName)) + Dispatcher.BeginInvoke(() => ApplyOpenWithButtonState(vm, host, btn)); + }; + vm.PropertyChanged += onChanged; + _openWithControls[vm.Id] = (host, btn, onChanged, vm); + ApplyOpenWithButtonState(vm, host, btn); + return host; + } + + /// Drops a pane's Open-with controls — on close, sleep, restart and failed launch. + private void RemoveOpenWithControls(string sessionId) + { + if (!_openWithControls.Remove(sessionId, out var c)) return; + c.vm.PropertyChanged -= c.onChanged; + } + + private void ApplyOpenWithButtonState(SessionViewModel vm, StackPanel host, WpfButton btn) + { + var entry = OpenWithContext.FromSession(vm.Session).HasTarget + ? OpenWithService.DefaultFor(_vm.Settings.OpenWith, vm.Session.Kind) + : null; + var vis = entry == null ? Visibility.Collapsed : Visibility.Visible; + if (host.Visibility != vis) host.Visibility = vis; + } + + /// Repaints every pane's { } after the list changed (seeded, edited, imported). + private void RefreshOpenWithButtons() + { + foreach (var id in _openWithControls.Keys.ToList()) + { + // No pruning here: a pane mid-launch is registered before its VM joins + // _vm.Sessions. Entries are removed at the teardown sites (RemoveOpenWithControls). + var c = _openWithControls[id]; + ApplyOpenWithButtonState(c.vm, c.host, c.btn); } - return btn; } - /// True when VS Code has been found (the lookup has finished and succeeded). - private static bool IsVsCodeAvailable() => - VsCodeLookup() is { IsCompletedSuccessfully: true, Result: not null }; + private void ShowOpenWithDropdown(SessionViewModel vm, WpfButton anchor) + { + var menu = new ContextMenu + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, + }; + foreach (var item in BuildOpenWithItems(vm)) menu.Items.Add(item); + menu.IsOpen = true; + } + + /// "Open with ▸" for the sidebar right-click menu. Null when the session has nothing to open. + private MenuItem? BuildOpenWithSubmenu(SessionViewModel vm) + { + if (!OpenWithContext.FromSession(vm.Session).HasTarget) return null; + var sub = new MenuItem { Header = "Open with" }; + foreach (var item in BuildOpenWithItems(vm)) sub.Items.Add(item); + return sub; + } - private void OpenInVsCode(string folder) + /// + /// One item per entry that has a template for this session's kind (the rest are hidden), + /// then "Edit…". The tooltip carries the program path, so what a click runs is visible. + /// + private List BuildOpenWithItems(SessionViewModel vm) { - _ = Task.Run(async () => + var items = new List(); + var ctx = OpenWithContext.FromSession(vm.Session); + foreach (var entry in OpenWithService.ApplicableTo(_vm.Settings.OpenWith, vm.Session.Kind)) { - try + // The program's file name is in the header and the full resolved command is the + // tooltip, so an entry labelled "VS Code" can't quietly be something else. + string exeName = System.IO.Path.GetFileName(entry.Executable); + var mi = new MenuItem { - string? codeExe = await VsCodeLookup().ConfigureAwait(false); - if (codeExe == null) return; - // Code.exe directly with the folder as one argv element — not code.cmd, which - // would put the folder through cmd.exe's parser (a folder named with & or % - // could then be read as a command). - var psi = new ProcessStartInfo(codeExe) { UseShellExecute = false }; - psi.ArgumentList.Add(folder); - Process.Start(psi)?.Dispose(); - } - catch (Exception ex) + Header = (entry.IsDefault ? $"{entry.Label} (default)" : entry.Label) + $" — {exeName}", + ToolTip = OpenWithService.Describe(entry, ctx), + }; + mi.Click += (_, _) => OpenSessionWith(vm, entry); + items.Add(mi); + } + if (items.Count == 0) + items.Add(new MenuItem { - _ = Dispatcher.BeginInvoke(() => ToastHelper.Show("Couldn't open VS Code", ex.Message)); - } - }); + Header = $"No editor is set up for {OpenWithService.KindName(vm.Session.Kind)} sessions", + IsEnabled = false, + }); + items.Add(new Separator()); + var edit = new MenuItem { Header = "Edit…" }; + edit.Click += (_, _) => OpenOpenWithEditor(); + items.Add(edit); + return items; } /// - /// Finds VS Code's Code.exe via the code.cmd launcher on PATH (it lives in - /// <install>\bin\). File checks only — no process is spawned to find it. - /// Misses installs not on PATH, Insiders, portable copies and other editors; see #178. + /// Snapshots the session on the UI thread, then hands everything else — the folder check, + /// a WSL $HOME probe, Process.Start — to on the pool. /// - private static string? FindVsCode() + private void OpenSessionWith(SessionViewModel vm, OpenWithEntry entry) { - try + var ctx = OpenWithContext.FromSession(vm.Session); + var snapshot = entry.Clone(); + _ = OpenWithService.LaunchAsync(snapshot, ctx).ContinueWith(t => { - string path = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (string dir in path.Split(';', StringSplitOptions.RemoveEmptyEntries)) - { - string d = dir.Trim().Trim('"'); - if (d.Length == 0 || d.StartsWith(@"\\", StringComparison.Ordinal)) continue; // skip network dirs - string cmd = Path.Combine(d, "code.cmd"); - if (!File.Exists(cmd)) continue; - string exe = Path.GetFullPath(Path.Combine(d, "..", "Code.exe")); - if (File.Exists(exe)) return exe; - } + string? error = t.IsCompletedSuccessfully ? t.Result : t.Exception?.GetBaseException().Message; + if (error == null) return; + OpenWithService.Log($"'{snapshot.Label}' for session '{vm.DisplayName}': {error}"); + _ = Dispatcher.BeginInvoke(() => ToastHelper.Show($"Couldn't open in {snapshot.Label}", error)); + }, TaskScheduler.Default); + } + + /// The list editor, reached from the ▼ menu, a right-click on { }, and the sidebar submenu. + private void OpenOpenWithEditor() + { + var dlg = new Views.OpenWithDialog(_vm.Settings.OpenWith) { Owner = this }; + if (dlg.ShowDialog() == true && dlg.Result != null) + { + _vm.Settings.OpenWith = dlg.Result; + _vm.Settings.OpenWithSeeded = true; + _ = _vm.SaveStateAsync(); + RefreshOpenWithButtons(); } - catch { } - return null; } } diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index af9e80b..e354e62 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -253,6 +253,7 @@ private async void OnLoaded(object sender, RoutedEventArgs e) await InitDatabaseAsync(); await _vm.LoadStateAsync(); RestoreWindowState(); + EnsureOpenWithSeeded(); // first run after #178: detect editors once, off the UI thread _windowStateReady = true; // Unattributed UI-thread latency baseline (issue #70). Started after settings load @@ -1588,6 +1589,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal TerminalGrid.Children.Remove(terminalWrapper); // BuildTerminalWrapper registered these; the pane is gone, so drop them too. _runControls.Remove(vm.Id); + RemoveOpenWithControls(vm.Id); _gitPanes.Remove(vm.Id); vm.Dispose(); if (_launchingSidebarItems.Remove(session.Id)) @@ -1723,6 +1725,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal TerminalGrid.Children.Remove(terminalWrapper); // BuildTerminalWrapper registered these; the pane is gone, so drop them too. _runControls.Remove(vm.Id); + RemoveOpenWithControls(vm.Id); _gitPanes.Remove(vm.Id); MessageBox.Show($"Failed to start '{session.FullCommandLine}':\n{ex.Message}", "Launch Error", MessageBoxButton.OK, MessageBoxImage.Error); @@ -3466,13 +3469,6 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM psItem.Click += (_, _) => LaunchPowerShellInFolder(vm.WorkingFolder, vm.GroupId); menu.Items.Add(psItem); - if (IsVsCodeAvailable()) - { - var codeItem = new System.Windows.Controls.MenuItem { Header = "Open in VS Code" }; - codeItem.Click += (_, _) => OpenInVsCode(vm.WorkingFolder); - menu.Items.Add(codeItem); - } - if (vm.Session.IsWsl) { var wslConsoleItem = new System.Windows.Controls.MenuItem { Header = "Open WSL console here" }; @@ -3481,6 +3477,11 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM } } + // "Open with ▸" (MainWindow.OpenIn.cs) — local, WSL and SSH alike; entries + // without a template for this session's kind are hidden. + if (BuildOpenWithSubmenu(vm) is { } openWithItem) + menu.Items.Add(openWithItem); + menu.Items.Add(new System.Windows.Controls.Separator()); var dupItem = new System.Windows.Controls.MenuItem { Header = "Duplicate session" }; @@ -4533,8 +4534,9 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) }; explorerBtn.Click += (_, _) => vm.OpenInExplorerCommand.Execute(null); - // { } — open the folder in VS Code (MainWindow.OpenIn.cs). Hidden unless found. - var vsCodeBtn = BuildVsCodeButton(vm); + // { } ▼ — open the folder with an editor from the "Open with" list + // (MainWindow.OpenIn.cs). Hidden when no entry applies to this session's kind. + var openWithBtns = BuildOpenWithButtons(vm); // PowerShell quick-launch button var toolbarPsBtn = new WpfButton @@ -4670,7 +4672,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) DockPanel.SetDock(closeBtn, Dock.Right); DockPanel.SetDock(termStatusDot, Dock.Right); DockPanel.SetDock(explorerBtn, Dock.Right); - DockPanel.SetDock(vsCodeBtn, Dock.Right); + DockPanel.SetDock(openWithBtns, Dock.Right); DockPanel.SetDock(toolbarPsBtn, Dock.Right); DockPanel.SetDock(notesBtn, Dock.Right); DockPanel.SetDock(sleepBtn, Dock.Right); @@ -4686,7 +4688,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) toolbarContent.Children.Add(closeBtn); toolbarContent.Children.Add(termStatusDot); toolbarContent.Children.Add(explorerBtn); - toolbarContent.Children.Add(vsCodeBtn); + toolbarContent.Children.Add(openWithBtns); toolbarContent.Children.Add(toolbarPsBtn); toolbarContent.Children.Add(notesBtn); toolbarContent.Children.Add(sleepBtn); @@ -4956,6 +4958,7 @@ private void OnSessionVmClosed(SessionViewModel vm) _sessionUiVersion++; } _runControls.Remove(vm.Id); + RemoveOpenWithControls(vm.Id); _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); @@ -5252,6 +5255,7 @@ private async Task RestartSessionCoreAsync(SessionViewModel vm, string? launched _sessionUiVersion++; } _runControls.Remove(vm.Id); + RemoveOpenWithControls(vm.Id); _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); @@ -5389,6 +5393,7 @@ private void SleepSession(SessionViewModel vm) _sessionUiVersion++; } _runControls.Remove(vm.Id); + RemoveOpenWithControls(vm.Id); _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); @@ -6013,6 +6018,12 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) _vm.Settings.ShowTerminalStatusDot = edited.ShowTerminalStatusDot; _vm.Settings.ImportWindowsTerminalProfiles = edited.ImportWindowsTerminalProfiles; _vm.Settings.LaunchCommands = edited.LaunchCommands; + if (dialog.OpenWithEdited) + { + _vm.Settings.OpenWith = edited.OpenWith; + _vm.Settings.OpenWithSeeded = true; + RefreshOpenWithButtons(); + } _vm.Settings.TerminalFontFamily = edited.TerminalFontFamily; _vm.Settings.TerminalFontSize = edited.TerminalFontSize; _vm.Settings.TerminalFontLigatures = edited.TerminalFontLigatures; @@ -6139,6 +6150,29 @@ private async void Import_Click(object sender, RoutedEventArgs e) _vm.Settings.MaxSearchResults = s.MaxSearchResults; _vm.Settings.ShowTerminalStatusDot = s.ShowTerminalStatusDot; _vm.Settings.LaunchCommands = s.LaunchCommands; + // "Open with" entries are programs that run on a click, and a backup is any file the + // user was handed. ImportExportService filtered it (no batch files, shells, network or + // %SystemRoot% programs), but no filter can recognise a renamed program — so the list + // is never adopted silently: show every command and ask, defaulting to keeping ours. + // A backup from before #178 carries no list; keep ours without asking. + if (s.OpenWithSeeded && !OpenWithService.SameCommands(s.OpenWith, _vm.Settings.OpenWith)) + { + string listing = s.OpenWith.Count == 0 + ? "(an empty list)\n" + : OpenWithService.DescribeForReview(s.OpenWith); + var adopt = MessageBox.Show( + "The backup contains its own \"Open with\" editor list. These programs would run when you " + + "use { } or Open with:\n\n" + listing + + "\nReplace your current list with this one?\n\nChoose No to keep your current list (recommended " + + "unless you made this backup yourself).", + "Import \"Open with\" list", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); + if (adopt == MessageBoxResult.Yes) + { + _vm.Settings.OpenWith = s.OpenWith; + _vm.Settings.OpenWithSeeded = true; + RefreshOpenWithButtons(); + } + } _vm.Settings.TerminalFontFamily = s.TerminalFontFamily; _vm.Settings.TerminalFontSize = s.TerminalFontSize; _vm.Settings.TerminalFontLigatures = s.TerminalFontLigatures; diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index f1a1dc5..e3b0be6 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -115,6 +115,15 @@ public class AppSettings public double TerminalLetterSpacing { get; set; } = 0; public double TerminalLineHeight { get; set; } = 1.0; + /// + /// "Open with…" editors / IDEs (issue #178). Seeded once by auto-detection — gated on + /// , not on the list being empty, so a user who deletes + /// every entry is not re-seeded behind their back. + /// + public List OpenWith { get; set; } = []; + /// True once auto-detection has seeded . False on any state.json older than #178. + public bool OpenWithSeeded { get; set; } = false; + public List LaunchCommands { get; set; } = [ "claude", diff --git a/src/CodeShellManager/Models/OpenWithEntry.cs b/src/CodeShellManager/Models/OpenWithEntry.cs new file mode 100644 index 0000000..b74dd52 --- /dev/null +++ b/src/CodeShellManager/Models/OpenWithEntry.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; + +namespace CodeShellManager.Models; + +/// +/// One "Open with…" target — an editor, IDE or folder viewer that a session's folder can be +/// opened in (issue #178). Stored in ; seeded once by +/// , then owned by the user. +/// +/// +/// Each argument template is a whitespace-separated list of tokens (double quotes group a +/// token containing spaces). Placeholders are substituted inside a token, after the +/// template has been split, so a value can never become more than one argv element. A +/// null/blank template means the entry does not apply to that session kind and is hidden +/// for it. See for the placeholders and the rules. +/// +public class OpenWithEntry +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Label { get; set; } = ""; + /// Absolute path to the program. Never a shell — see . + public string Executable { get; set; } = ""; + /// Arguments for a local session — e.g. {folder}. + public string? LocalArgs { get; set; } + /// Arguments for a WSL session — e.g. --remote wsl+{distro} {linuxPath}. + public string? WslArgs { get; set; } + /// Arguments for an SSH session — e.g. --remote ssh-remote+{userAtHost} {remoteFolder}. + public string? SshArgs { get; set; } + /// Exactly one entry is the default — the toolbar { } button opens it. + public bool IsDefault { get; set; } + + public string? ArgsFor(SessionKind kind) => kind switch + { + SessionKind.Local => LocalArgs, + SessionKind.Wsl => WslArgs, + SessionKind.Ssh => SshArgs, + _ => null, + }; + + public bool AppliesTo(SessionKind kind) => !string.IsNullOrWhiteSpace(ArgsFor(kind)); + + public OpenWithEntry Clone() => new() + { + Id = Id, + Label = Label, + Executable = Executable, + LocalArgs = LocalArgs, + WslArgs = WslArgs, + SshArgs = SshArgs, + IsDefault = IsDefault, + }; + + /// Leaves exactly one default when the list is non-empty: the first flagged, else the first entry. + public static void EnsureSingleDefault(IList list) + { + bool seen = false; + foreach (var e in list) + { + if (e.IsDefault && !seen) seen = true; + else e.IsDefault = false; + } + if (!seen && list.Count > 0) list[0].IsDefault = true; + } +} diff --git a/src/CodeShellManager/Services/ImportExportService.cs b/src/CodeShellManager/Services/ImportExportService.cs index f5f1bfa..13459dd 100644 --- a/src/CodeShellManager/Services/ImportExportService.cs +++ b/src/CodeShellManager/Services/ImportExportService.cs @@ -31,7 +31,16 @@ public static async Task ExportAsync(AppState state, string path) // cover an ABSENT key — an explicit "Sessions": null deserializes to a real // null. This is the untrusted path (any file the user points at), and the // caller dereferences Settings immediately, outside its own try block. - return state is null ? null : StateService.Normalize(state); + if (state is null) return null; + StateService.Normalize(state); + // An imported "Open with" list is a list of programs to launch, from a file the + // user didn't necessarily write. Stricter than state.json: no batch files, and each + // program is checked against its real path on disk (8.3 names, links, network + // drives, renamed shells). Even then it is only a filter — the caller must still + // show the list and ask before adopting it (MainWindow.Import_Click). + var imported = state.Settings.OpenWith; + state.Settings.OpenWith = await Task.Run(() => OpenWithService.SanitizeImported(imported, OpenWithService.Log)); + return state; } catch { diff --git a/src/CodeShellManager/Services/OpenWithDetector.cs b/src/CodeShellManager/Services/OpenWithDetector.cs new file mode 100644 index 0000000..35eafdb --- /dev/null +++ b/src/CodeShellManager/Services/OpenWithDetector.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using CodeShellManager.Models; + +namespace CodeShellManager.Services; + +/// +/// Finds installed editors / IDEs to seed (issue #178). +/// Runs once per profile — after that the list belongs to the user, like run-command +/// templates — and again only when the user clicks "Detect" in the editor. +/// +/// +/// All of this is disk and registry I/O, and Visual Studio needs a vswhere spawn: call +/// from Task.Run, never on the UI thread. See "Never spawn a +/// process on the UI thread" in CLAUDE.md. Every probe is wrapped so one broken install can't +/// stop the rest being found. +/// +public static class OpenWithDetector +{ + /// Where to look. Injectable so the probes can be tested against a temp tree. + public sealed class DetectorEnvironment + { + public string Path { get; init; } = ""; + public string LocalAppData { get; init; } = ""; + public string ProgramFiles { get; init; } = ""; + public string ProgramFilesX86 { get; init; } = ""; + public string UserProfile { get; init; } = ""; + /// App Paths lookup: exe file name → full path (HKCU then HKLM). Null = none. + public Func AppPath { get; init; } = _ => null; + /// Runs vswhere and returns its JSON, or null. Null = skip Visual Studio. + public Func VsWhereJson { get; init; } = () => null; + + public static DetectorEnvironment Current() => new() + { + Path = System.Environment.GetEnvironmentVariable("PATH") ?? "", + LocalAppData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData), + ProgramFiles = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFiles), + ProgramFilesX86 = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFilesX86), + UserProfile = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + AppPath = RegistryAppPath, + VsWhereJson = RunVsWhere, + }; + } + + // VS Code and its forks share the CLI flags, including the remote ones. The remote forms + // are ONE argument (`--folder-uri vscode-remote://…`) rather than `--remote wsl+X /path`: + // a bare Linux path starts with '/', and Chromium reads `/name=value` as a switch on + // Windows (verified against Code.exe — see OpenWithService's remarks). The `:uri` forms + // percent-encode the values; VS Code's URI.parse decodes authority and path. + public const string VsCodeLocalArgs = "{folder}"; + public const string VsCodeWslArgs = "--folder-uri vscode-remote://wsl+{distro:uri}{linuxPath:uri}"; + public const string VsCodeSshArgs = "--folder-uri vscode-remote://ssh-remote+{userAtHost:uri}{remoteFolder:uri}"; + + private sealed record VsCodeFamily(string Label, string Shim, string Exe, string[] InstallDirs, string[] ScoopApps); + + private static readonly VsCodeFamily[] VsCodeFamilies = + [ + new("VS Code", "code.cmd", "Code.exe", ["Microsoft VS Code"], ["vscode"]), + new("VS Code Insiders", "code-insiders.cmd", "Code - Insiders.exe", ["Microsoft VS Code Insiders"], ["vscode-insiders"]), + new("Cursor", "cursor.cmd", "Cursor.exe", ["cursor", "Cursor"], ["cursor"]), + new("Windsurf", "windsurf.cmd", "Windsurf.exe", ["Windsurf", "windsurf"], ["windsurf"]), + new("VSCodium", "codium.cmd", "VSCodium.exe", ["VSCodium"], ["vscodium"]), + ]; + + /// JetBrains launcher exe → product name. The 64-bit launchers, which every current IDE ships. + private static readonly (string Exe, string Label)[] JetBrainsProducts = + [ + ("rider64.exe", "Rider"), + ("idea64.exe", "IntelliJ IDEA"), + ("pycharm64.exe", "PyCharm"), + ("webstorm64.exe", "WebStorm"), + ("goland64.exe", "GoLand"), + ("clion64.exe", "CLion"), + ("phpstorm64.exe", "PhpStorm"), + ("rubymine64.exe", "RubyMine"), + ("rustrover64.exe", "RustRover"), + ("datagrip64.exe", "DataGrip"), + ("dataspell64.exe", "DataSpell"), + ]; + + public static List Detect() => Detect(DetectorEnvironment.Current()); + + public static List Detect(DetectorEnvironment env) + { + var found = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(OpenWithEntry e) + { + string key; + try { key = System.IO.Path.GetFullPath(e.Executable); } catch { return; } + if (OpenWithService.ValidateExecutable(e.Executable, allowBatch: false) != null) return; + if (seen.Add(key)) found.Add(e); + } + + foreach (var fam in VsCodeFamilies) + Try(() => { if (FindVsCodeFamily(fam, env) is { } exe) Add(VsCodeEntry(fam.Label, exe)); }); + + Try(() => { foreach (var e in FindVisualStudio(env)) Add(e); }); + Try(() => { foreach (var e in FindJetBrains(env)) Add(e); }); + Try(() => { foreach (var e in FindSimpleEditors(env)) Add(e); }); + + OpenWithEntry.EnsureSingleDefault(found); // VS Code first when present — today's behaviour + return found; + } + + public static OpenWithEntry VsCodeEntry(string label, string exe) => new() + { + Label = label, + Executable = exe, + LocalArgs = VsCodeLocalArgs, + WslArgs = VsCodeWslArgs, + SshArgs = VsCodeSshArgs, + }; + + private static void Try(Action probe) + { + try { probe(); } catch { /* one broken install must not stop the rest */ } + } + + // ── VS Code family ──────────────────────────────────────────────────────────────────── + + /// + /// PATH shim first — today's zero-config lookup, and the install the user's shell + /// actually runs — then App Paths, then the standard per-user / per-machine folders and + /// Scoop. The shim is resolved to the exe; the batch file itself is never used. + /// + internal static string? FindVsCodeFamily(string label, DetectorEnvironment env) => + FindVsCodeFamily(VsCodeFamilies.First(f => f.Label == label), env); + + private static string? FindVsCodeFamily(VsCodeFamily fam, DetectorEnvironment env) + { + foreach (string dir in PathDirs(env.Path)) + { + string shim = System.IO.Path.Combine(dir, fam.Shim); + if (!File.Exists(shim)) continue; + string? exe = OpenWithService.ResolveShim(shim); + if (exe != null && File.Exists(exe)) return exe; + } + if (env.AppPath(fam.Exe) is { } appPath && File.Exists(appPath)) return appPath; + foreach (string dirName in fam.InstallDirs) + { + foreach (string root in new[] { System.IO.Path.Combine(env.LocalAppData, "Programs"), env.ProgramFiles, env.ProgramFilesX86 }) + { + if (string.IsNullOrEmpty(root)) continue; + string exe = System.IO.Path.Combine(root, dirName, fam.Exe); + if (File.Exists(exe)) return exe; + } + } + foreach (string app in fam.ScoopApps) + { + if (string.IsNullOrEmpty(env.UserProfile)) break; + string exe = System.IO.Path.Combine(env.UserProfile, "scoop", "apps", app, "current", fam.Exe); + if (File.Exists(exe)) return exe; + } + return null; + } + + private static IEnumerable PathDirs(string path) + { + foreach (string raw in (path ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + string d = raw.Trim().Trim('"'); + // Skip relative entries and network dirs — a PATH entry on a share is not a place + // to pick up a program to launch on a click. + if (d.Length == 0 || d.StartsWith(@"\\", StringComparison.Ordinal)) continue; + bool qualified; + try { qualified = System.IO.Path.IsPathFullyQualified(d); } catch { qualified = false; } + if (qualified) yield return d; + } + } + + // ── Visual Studio ───────────────────────────────────────────────────────────────────── + + private static IEnumerable FindVisualStudio(DetectorEnvironment env) + { + string? json = env.VsWhereJson(); + return json == null ? [] : ParseVsWhere(json); + } + + /// + /// vswhere -format json → one entry per instance with a devenv.exe. Visual + /// Studio opens a folder with devenv.exe <folder> (Open Folder mode). Local + /// only — it has no WSL/SSH remote form worth seeding. + /// + internal static List ParseVsWhere(string json) + { + var list = new List(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return list; + foreach (var inst in doc.RootElement.EnumerateArray()) + { + string? productPath = inst.TryGetProperty("productPath", out var pp) ? pp.GetString() : null; + if (string.IsNullOrEmpty(productPath) + || !System.IO.Path.GetFileName(productPath).Equals("devenv.exe", StringComparison.OrdinalIgnoreCase)) + continue; + string label = inst.TryGetProperty("displayName", out var dn) && dn.GetString() is { Length: > 0 } name + ? name + : "Visual Studio"; + list.Add(new OpenWithEntry { Label = label, Executable = productPath, LocalArgs = "{folder}" }); + } + return list; + } + + private static string? RunVsWhere() + { + string pf86 = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFilesX86); + string vswhere = System.IO.Path.Combine(pf86, "Microsoft Visual Studio", "Installer", "vswhere.exe"); + if (!File.Exists(vswhere)) return null; + var psi = new ProcessStartInfo(vswhere) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + }; + foreach (var a in new[] { "-all", "-prerelease", "-format", "json", "-utf8", "-nologo" }) + psi.ArgumentList.Add(a); + using var p = Process.Start(psi); + if (p == null) return null; + var stdout = p.StandardOutput.ReadToEndAsync(); + _ = p.StandardError.ReadToEndAsync(); + if (!p.WaitForExit(5000)) + { + try { p.Kill(entireProcessTree: true); } catch { } + return null; + } + return p.ExitCode == 0 ? stdout.Result : null; + } + + // ── JetBrains ───────────────────────────────────────────────────────────────────────── + + /// + /// Toolbox 2.x installs to %LOCALAPPDATA%\Programs\<IDE>\bin, Toolbox 1.x to + /// %LOCALAPPDATA%\JetBrains\Toolbox\apps\<product>\ch-N\<build>\bin, and a + /// standalone install to %ProgramFiles%\JetBrains\<IDE>\bin. The exe is launched + /// directly — never the Toolbox .cmd scripts. Newest file wins per product. + /// + private static IEnumerable FindJetBrains(DetectorEnvironment env) + { + var binDirs = new List(); + void AddChildBins(string root) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) return; + foreach (var d in Directory.EnumerateDirectories(root)) binDirs.Add(System.IO.Path.Combine(d, "bin")); + } + AddChildBins(System.IO.Path.Combine(env.LocalAppData, "Programs")); + AddChildBins(System.IO.Path.Combine(env.ProgramFiles, "JetBrains")); + string toolboxApps = System.IO.Path.Combine(env.LocalAppData, "JetBrains", "Toolbox", "apps"); + if (Directory.Exists(toolboxApps)) + { + foreach (var product in Directory.EnumerateDirectories(toolboxApps)) + foreach (var channel in Directory.EnumerateDirectories(product)) + foreach (var build in Directory.EnumerateDirectories(channel)) + binDirs.Add(System.IO.Path.Combine(build, "bin")); + } + + var best = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var bin in binDirs) + { + if (!Directory.Exists(bin)) continue; + foreach (var (exeName, _) in JetBrainsProducts) + { + string exe = System.IO.Path.Combine(bin, exeName); + if (!File.Exists(exe)) continue; + var when = File.GetLastWriteTimeUtc(exe); + if (!best.TryGetValue(exeName, out var cur) || when > cur.When) best[exeName] = (exe, when); + } + } + foreach (var (exeName, label) in JetBrainsProducts) + { + if (best.TryGetValue(exeName, out var hit)) + // JetBrains IDEs open a \\wsl$ folder natively, so the WSL form is the UNC mirror. + yield return new OpenWithEntry { Label = label, Executable = hit.Path, LocalArgs = "{folder}", WslArgs = "{folder}" }; + } + } + + // ── Plain editors ───────────────────────────────────────────────────────────────────── + + private static IEnumerable FindSimpleEditors(DetectorEnvironment env) + { + // (label, App Paths name, candidate paths relative to roots, args) + var zed = FirstExisting(env.AppPath("Zed.exe"), + System.IO.Path.Combine(env.LocalAppData, "Programs", "Zed", "Zed.exe")); + if (zed != null) yield return new OpenWithEntry { Label = "Zed", Executable = zed, LocalArgs = "{folder}", WslArgs = "{folder}" }; + + var subl = FirstExisting( + System.IO.Path.Combine(env.ProgramFiles, "Sublime Text", "subl.exe"), + System.IO.Path.Combine(env.ProgramFiles, "Sublime Text 3", "subl.exe"), + env.AppPath("sublime_text.exe")); + if (subl != null) yield return new OpenWithEntry { Label = "Sublime Text", Executable = subl, LocalArgs = "{folder}", WslArgs = "{folder}" }; + + var npp = FirstExisting(env.AppPath("notepad++.exe"), + System.IO.Path.Combine(env.ProgramFiles, "Notepad++", "notepad++.exe"), + System.IO.Path.Combine(env.ProgramFilesX86, "Notepad++", "notepad++.exe")); + if (npp != null) + yield return new OpenWithEntry + { + Label = "Notepad++", + Executable = npp, + LocalArgs = "-openFoldersAsWorkspace {folder}", + WslArgs = "-openFoldersAsWorkspace {folder}", + }; + } + + private static string? FirstExisting(params string?[] candidates) + { + foreach (var c in candidates) + { + if (string.IsNullOrEmpty(c)) continue; + try { if (File.Exists(c)) return c; } catch { } + } + return null; + } + + // ── Registry ────────────────────────────────────────────────────────────────────────── + + /// …\CurrentVersion\App Paths\<exe> default value, HKCU then HKLM. + private static string? RegistryAppPath(string exeName) + { + foreach (var hive in new[] { Microsoft.Win32.Registry.CurrentUser, Microsoft.Win32.Registry.LocalMachine }) + { + try + { + using var key = hive.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exeName}"); + if (key?.GetValue(null) is string v && v.Length > 0) + { + string expanded = System.Environment.ExpandEnvironmentVariables(v.Trim().Trim('"')); + if (File.Exists(expanded)) return expanded; + } + } + catch { } + } + return null; + } +} diff --git a/src/CodeShellManager/Services/OpenWithService.cs b/src/CodeShellManager/Services/OpenWithService.cs new file mode 100644 index 0000000..c0ce35f --- /dev/null +++ b/src/CodeShellManager/Services/OpenWithService.cs @@ -0,0 +1,739 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CodeShellManager.Models; +using Microsoft.Win32.SafeHandles; + +namespace CodeShellManager.Services; + +/// +/// The values a session offers to an "Open with…" argument template. Built on the UI thread +/// from the live (a snapshot — the session is mutated there), then +/// handed to off it. +/// +public sealed record OpenWithContext( + SessionKind Kind, + string Folder = "", + string Distro = "", + string LinuxPath = "", + string WslUser = "", + string User = "", + string Host = "", + int Port = 22, + string RemoteFolder = "") +{ + public static OpenWithContext FromSession(ShellSession s) => new( + s.Kind, + Folder: s.WorkingFolder ?? "", + Distro: s.WslDistro ?? "", + LinuxPath: s.WslWorkingFolder ?? "", + WslUser: s.WslUser ?? "", + User: s.SshUser ?? "", + Host: s.SshHost ?? "", + Port: s.SshPort, + RemoteFolder: s.SshRemoteFolder ?? ""); + + /// + /// Whether a session of this shape has anything to open: a local folder, a distro, or a + /// host. Pure — no disk access. + /// + public bool HasTarget => Kind switch + { + SessionKind.Local => !string.IsNullOrWhiteSpace(Folder), + SessionKind.Wsl => !string.IsNullOrWhiteSpace(Distro), + SessionKind.Ssh => !string.IsNullOrWhiteSpace(Host), + _ => false, + }; +} + +/// A resolved launch: the program and its argv, each element passed as-is via . +public sealed record OpenWithCommand(string Executable, IReadOnlyList Arguments) +{ + /// + /// The command line, for the editor preview, menu tooltips and error messages. Never + /// executed. Quoted with , which encodes the same + /// argv as .NET's own ArgumentList encoding (both are MSVCRT rules — see + /// OpenWithServiceTests.ArgumentList_RealProcess_*), though the spelling can differ. + /// + public string Display => + string.Join(" ", new[] { ShellSession.QuoteForCmd(Executable) } + .Concat(Arguments.Select(a => ShellSession.QuoteForCmd(a)))); + + public ProcessStartInfo ToStartInfo() + { + // UseShellExecute=false: CreateProcess on the program itself, no shell and no file + // association lookup. ArgumentList: each element is quoted on its own (MSVCRT rules), + // so a placeholder value cannot spill into a second argument. + var psi = new ProcessStartInfo(Executable) { UseShellExecute = false }; + foreach (var a in Arguments) psi.ArgumentList.Add(a); + return psi; + } +} + +/// +/// "Open with…" (issue #178): template parsing, placeholder substitution and every +/// validation rule — WPF-free so it is unit-tested. See "Open with…" in CLAUDE.md. +/// +/// +/// Never a command line built by interpolation. A template is split into tokens +/// first; only then are placeholders substituted, each inside its own token. A folder named +/// a" --x "b therefore stays one argv element, and the process is started with +/// UseShellExecute=false + ArgumentList, so no shell ever parses it. +/// Batch files are the exception that has to be fenced. CreateProcess runs a +/// .cmd/.bat through cmd.exe, which re-parses the command line with its +/// own rules — % expands even inside quotes, & chains a second command +/// (the BatBadBut class, CVE-2024-24576). Detection resolves the known shims +/// (code.cmd → Code.exe) so the seeded list holds none; when the user insists on +/// a batch file anyway, every argument containing a cmd metacharacter is refused — never +/// escaped. An import never accepts one at all. +/// An argument must not be readable as a switch. A value that starts an argv +/// element may not begin with - or /: Chromium (so every Electron editor) +/// accepts /name=value as a switch on Windows — verified against Code.exe 1.143.1, +/// where /js-flags=… reached the renderer as --js-flags=…. That is why the seeded +/// VS Code remote forms are the single-token --folder-uri vscode-remote://… rather than +/// a bare Linux path argument. +/// +public static class OpenWithService +{ + /// Placeholders each session kind provides. Anything else in a template is an error, not literal text. + public static readonly IReadOnlyDictionary PlaceholdersByKind = + new Dictionary + { + [SessionKind.Local] = ["folder"], + [SessionKind.Wsl] = ["folder", "distro", "linuxPath"], + [SessionKind.Ssh] = ["user", "host", "userAtHost", "port", "remoteFolder"], + }; + + /// Placeholders whose :uri form keeps / (a path), rather than encoding it. + private static readonly HashSet PathPlaceholders = new(StringComparer.Ordinal) { "linuxPath", "remoteFolder" }; + + /// + /// Characters cmd.exe interprets in a batch file's command line, quoted or not. % + /// and ! expand variables, & | < > chain and redirect, ^ + /// escapes, " toggles cmd's quote state, ( ) group, and , ; = are + /// argument delimiters to a batch script. + /// + public const string BatchMetaChars = "%!&|<>^\"(),;="; + + /// + /// Programs that run text (or an argument) as code. Checked against the file name, the + /// long-path name and the PE version-info original name. This is a speed bump, not the + /// boundary: a renamed copy of an arbitrary program can't be recognised by name, which is + /// why imported lists are confirmed by the user and why everything under %SystemRoot% is + /// refused outright. + /// + private static readonly HashSet ShellLikeNames = new(StringComparer.OrdinalIgnoreCase) + { + "cmd", "powershell", "powershell_ise", "pwsh", "wsl", "bash", "sh", "wscript", "cscript", + "mshta", "rundll32", "regsvr32", "msiexec", "conhost", "wt", "schtasks", "forfiles", + "runas", "start", "reg", "certutil", "bitsadmin", "ssh", "scp", "sftp", "wmic", "pcalua", + "mmc", "hh", "explorer", "msbuild", "installutil", "odbcconf", "regasm", "regsvcs", "cmstp", + "msxsl", "ieexec", "dotnet", "node", "python", "pythonw", "py", "pyw", "perl", "ruby", + "java", "javaw", "csi", "fsi", "winrm", "psexec", "at", + }; + + private static readonly Regex PlaceholderRegex = new(@"\{([A-Za-z]+)(:uri)?\}", RegexOptions.CultureInvariant); + + public const int MaxEntries = 50; + + // ── Executable ──────────────────────────────────────────────────────────────────────── + + public static bool IsBatch(string? path) + { + string ext = Path.GetExtension(path ?? ""); + return ext.Equals(".cmd", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".bat", StringComparison.OrdinalIgnoreCase); + } + + /// %SystemRoot%, e.g. C:\Windows. Overridable for tests. + internal static string SystemRoot { get; set; } = + Environment.GetFolderPath(Environment.SpecialFolder.Windows) is { Length: > 0 } w ? w : @"C:\Windows"; + + /// + /// Null when is acceptable as an entry's program, else the reason. + /// Pure — no disk access; repeats the checks against + /// the real path at import and launch time. Rules: an absolute local path (no relative path, + /// so nothing is resolved against PATH or the cwd; no UNC/device path, so nothing runs from + /// a share), a .exe — or a .cmd/.bat only when + /// — not under %SystemRoot%, and not a known shell or script host. + /// + public static string? ValidateExecutable(string? path, bool allowBatch) + { + if (string.IsNullOrWhiteSpace(path)) return "No program is set."; + if (path.Any(char.IsControl)) return "The program path contains control characters."; + if (path.StartsWith(@"\\", StringComparison.Ordinal) || path.StartsWith("//", StringComparison.Ordinal)) + return "Network and device paths are not accepted — use a program on this machine."; + bool qualified; + try { qualified = Path.IsPathFullyQualified(path); } catch { qualified = false; } + if (!qualified) return "The program must be a full path, e.g. C:\\Program Files\\Editor\\editor.exe."; + + if (IsBatch(path)) + { + if (!allowBatch) + return "Batch files (.cmd/.bat) run through cmd.exe and are not accepted here — point at the program's .exe."; + } + else if (!Path.GetExtension(path).Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + return "The program must be an .exe."; + } + + if (IsUnder(path, SystemRoot)) + return $"Programs under {SystemRoot} are not accepted — no editor lives there, and many tools there run commands from their arguments."; + if (ShellLikeNames.Contains(Path.GetFileNameWithoutExtension(path))) + return $"{Path.GetFileName(path)} is a shell or script host, not an editor — it is not accepted."; + return null; + } + + /// + /// against the program's REAL path: 8.3 short names + /// expanded (POWERS~1.EXE), junctions, symlinks and subst drives followed, then a + /// network drive refused (mapped or subst'd onto a share — the final path is UNC, or the + /// drive type is Network), and the PE original file name checked too, so a renamed copy of + /// a known shell is caught. A program that doesn't exist yet passes (it fails at launch). + /// Touches the disk — call it off the UI thread. + /// + public static string? ValidateExecutableOnDisk(string? path, bool allowBatch, Func? driveTypeOf = null) + { + string? shapeError = ValidateExecutable(path, allowBatch); + if (shapeError != null) return shapeError; + driveTypeOf ??= DriveTypeOf; + try + { + if (driveTypeOf(Path.GetPathRoot(path!)!) == DriveType.Network) + return "Programs on a network drive are not accepted — use a program on this machine."; + if (!File.Exists(path)) return null; + + string? real = FinalPath(path!); + if (real != null && !string.Equals(real, path, StringComparison.OrdinalIgnoreCase)) + { + if (real.StartsWith(@"\\", StringComparison.Ordinal)) + return "That program resolves to a network location — it is not accepted."; + if (driveTypeOf(Path.GetPathRoot(real)!) == DriveType.Network) + return "Programs on a network drive are not accepted — use a program on this machine."; + string? realError = ValidateExecutable(real, allowBatch); + if (realError != null) return realError; + } + + var info = FileVersionInfo.GetVersionInfo(path!); + foreach (var name in new[] { info.OriginalFilename, info.InternalName }) + { + if (string.IsNullOrWhiteSpace(name)) continue; + string bare = Path.GetFileNameWithoutExtension(name.Trim().Replace(".mui", "", StringComparison.OrdinalIgnoreCase)); + if (ShellLikeNames.Contains(bare)) + return $"{Path.GetFileName(path)} is a copy of {name.Trim()}, a shell or script host — it is not accepted."; + } + } + catch (Exception ex) + { + return $"Couldn't check the program: {ex.Message}"; + } + return null; + } + + private static DriveType DriveTypeOf(string root) + { + try { return new DriveInfo(root).DriveType; } catch { return DriveType.Unknown; } + } + + private static bool IsUnder(string path, string root) + { + if (string.IsNullOrEmpty(root)) return false; + try + { + string p = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + string r = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + return p.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || p.Equals(r, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + /// The file's final path (GetFinalPathNameByHandle): long names, links and subst resolved. Null on failure. + internal static string? FinalPath(string path) + { + try + { + using var h = CreateFileW(Path.GetFullPath(path), 0, 0x7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); + if (h.IsInvalid) return null; + var buf = new char[2048]; + uint n = GetFinalPathNameByHandleW(h, buf, (uint)buf.Length, 0); + if (n == 0 || n >= buf.Length) return null; + string final = new string(buf, 0, (int)n); + if (final.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) return @"\\" + final[8..]; + if (final.StartsWith(@"\\?\", StringComparison.Ordinal)) return final[4..]; + return final; + } + catch { return null; } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h, [Out] char[] buf, uint len, uint flags); + + // ── Templates ───────────────────────────────────────────────────────────────────────── + + /// + /// Splits a template into tokens on whitespace; a "…" span groups text containing + /// spaces into one token (the quotes are removed — there is no escape syntax, and a + /// literal " cannot be written). Returns null on an unbalanced quote. + /// + public static List? Tokenize(string? template) + { + var tokens = new List(); + if (string.IsNullOrWhiteSpace(template)) return tokens; + var sb = new StringBuilder(); + bool inQuotes = false, inToken = false; + foreach (char c in template) + { + if (c == '"') { inQuotes = !inQuotes; inToken = true; continue; } + if (!inQuotes && char.IsWhiteSpace(c)) + { + if (inToken) { tokens.Add(sb.ToString()); sb.Clear(); inToken = false; } + continue; + } + sb.Append(c); + inToken = true; + } + if (inQuotes) return null; + if (inToken) tokens.Add(sb.ToString()); + return tokens; + } + + /// + /// Null when the template is usable for , else the reason. Blank is + /// valid (= not offered for that kind). A non-blank template must use at least one + /// placeholder: an Open-with entry opens the session's location, and a template of only + /// literal arguments would just be a fixed command line behind a friendly label. + /// + public static string? ValidateTemplate(string? template, SessionKind kind) + { + if (string.IsNullOrWhiteSpace(template)) return null; + if (template.Any(char.IsControl)) return "The arguments contain control characters."; + var tokens = Tokenize(template); + if (tokens == null) return "Unbalanced quote in the arguments."; + var allowed = PlaceholdersByKind[kind]; + int count = 0; + foreach (var token in tokens) + { + foreach (Match m in PlaceholderRegex.Matches(token)) + { + count++; + string name = m.Groups[1].Value; + if (!allowed.Contains(name, StringComparer.Ordinal)) + return $"{{{name}}} is not available for {KindName(kind)} sessions (use {string.Join(", ", allowed.Select(a => "{" + a + "}"))})."; + } + } + if (count == 0) + return $"The arguments must use a placeholder for the session's location (e.g. {string.Join(", ", allowed.Select(a => "{" + a + "}"))})."; + return null; + } + + public static string KindName(SessionKind kind) => kind switch + { + SessionKind.Local => "local", + SessionKind.Wsl => "WSL", + SessionKind.Ssh => "SSH", + _ => kind.ToString(), + }; + + // ── Build ───────────────────────────────────────────────────────────────────────────── + + /// + /// Resolves for into a program + argv, or + /// returns the reason it can't. Pure: no disk access (folder existence and the program + /// file are checked by ). + /// + public static (OpenWithCommand? Command, string? Error) Build(OpenWithEntry entry, OpenWithContext ctx) + { + string? exeError = ValidateExecutable(entry.Executable, allowBatch: true); + if (exeError != null) return (null, exeError); + + string? template = entry.ArgsFor(ctx.Kind); + if (string.IsNullOrWhiteSpace(template)) + return (null, $"\"{entry.Label}\" has no arguments for {KindName(ctx.Kind)} sessions."); + string? templateError = ValidateTemplate(template, ctx.Kind); + if (templateError != null) return (null, templateError); + + var values = ValuesFor(ctx); + var args = new List(); + foreach (var token in Tokenize(template)!) + { + var matches = PlaceholderRegex.Matches(token); + if (matches.Count == 0) { args.Add(token); continue; } + + // A token that is nothing but one placeholder, whose value is empty, is dropped + // rather than passed as "". + if (matches.Count == 1 && matches[0].Value == token && values[matches[0].Groups[1].Value].Length == 0) + continue; + + foreach (Match m in matches) + { + string name = m.Groups[1].Value; + string value = values[name]; + string? valueError = ValidateValue(name, value); + if (valueError != null) return (null, valueError); + // Empty inside a larger token (`vscode-remote://ssh-remote+box{remoteFolder:uri}`) + // would silently produce a different target. Only {user} may be empty. + if (value.Length == 0 && name != "user") + return (null, MissingValueMessage(name, ctx.Kind)); + } + + string arg = PlaceholderRegex.Replace(token, m => Encode(m.Groups[1].Value, values[m.Groups[1].Value], m.Groups[2].Success)); + // A placeholder that STARTS an argv element must not make it switch-shaped. '-' is + // refused for every value already; '/' is refused here because Chromium/Electron + // parse /name=value as a switch on Windows (see the class remarks). + if (matches[0].Index == 0 && (arg.StartsWith('-') || arg.StartsWith('/'))) + return (null, $"The session's {matches[0].Groups[1].Value} would start an argument with '{arg[0]}', " + + "which the program could read as a switch. Put it inside a larger argument " + + "(e.g. --folder-uri vscode-remote://…{linuxPath:uri})."); + args.Add(arg); + } + + if (IsBatch(entry.Executable)) + { + // BatBadBut: cmd.exe re-parses a batch file's arguments. Refuse, never escape — + // there is no escaping that is correct for every batch script. + foreach (var a in args) + { + int bad = a.IndexOfAny(BatchMetaChars.ToCharArray()); + if (bad >= 0) + return (null, $"{Path.GetFileName(entry.Executable)} is a batch file, which runs through cmd.exe, " + + $"and the argument \"{a}\" contains '{a[bad]}', which cmd.exe would interpret. " + + "Point the entry at the program's .exe instead."); + } + } + + return (new OpenWithCommand(entry.Executable, args), null); + } + + private static string MissingValueMessage(string name, SessionKind kind) => name switch + { + "remoteFolder" => "This SSH session has no remote folder. Set one with Edit session…, or use an editor entry that can open a host without a folder.", + "linuxPath" => "Couldn't work out this WSL session's Linux folder. Set one with Edit session….", + _ => $"This {KindName(kind)} session has no {name}.", + }; + + /// + /// The :uri form, for a value spliced into a URI such as + /// vscode-remote://wsl+{distro:uri}{linuxPath:uri}. Path placeholders encode each + /// segment and keep /; anything else is encoded whole (so @ in + /// {userAtHost:uri} becomes %40 and cannot read as URI user-info). VS Code's + /// URI.parse percent-decodes both the authority and the path. + /// + internal static string Encode(string name, string value, bool uri) + { + if (!uri) return value; + if (PathPlaceholders.Contains(name)) + return string.Join("/", value.Split('/').Select(Uri.EscapeDataString)); + return Uri.EscapeDataString(value); + } + + private static Dictionary ValuesFor(OpenWithContext ctx) + { + string user = ctx.User?.Trim() ?? ""; + string host = ctx.Host?.Trim() ?? ""; + return new Dictionary(StringComparer.Ordinal) + { + ["folder"] = ctx.Folder ?? "", + ["distro"] = ctx.Distro?.Trim() ?? "", + ["linuxPath"] = ctx.LinuxPath ?? "", + ["user"] = user, + ["host"] = host, + ["userAtHost"] = user.Length == 0 ? host : $"{user}@{host}", + ["port"] = ctx.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["remoteFolder"] = ctx.RemoteFolder ?? "", + }; + } + + /// + /// Rules for a substituted value. Every value comes from state.json / an import / the + /// user's own session — untrusted. Argv quoting already keeps it one element; these stop + /// it being read as something else by the program that receives it. + /// + private static string? ValidateValue(string name, string value) + { + if (value.Any(char.IsControl)) + return $"The session's {name} contains control characters."; + // An option-shaped value (-x, --foo) could be taken by the editor as a flag. Refused + // wherever it sits — stricter than needed, never wrong for a path, host or distro. + if (value.StartsWith('-')) + return $"The session's {name} starts with '-', which the program could read as an option."; + switch (name) + { + case "folder": + if (value.Length == 0) break; + bool qualified; + try { qualified = Path.IsPathFullyQualified(value); } catch { qualified = false; } + if (!qualified) return "The session's folder is not a full path."; + break; + case "host": + case "user": + case "userAtHost": + case "distro": + if (value.Any(char.IsWhiteSpace)) return $"The session's {name} contains whitespace."; + break; + case "port": + if (!int.TryParse(value, out int p) || p < 1 || p > 65535) return "The session's port is out of range."; + break; + } + return null; + } + + // ── Selection / display ─────────────────────────────────────────────────────────────── + + public static IEnumerable ApplicableTo(IEnumerable? list, SessionKind kind) => + (list ?? []).Where(e => e != null && e.AppliesTo(kind)); + + /// The default entry when it applies to this kind; otherwise the first entry that does. Null when none does. + public static OpenWithEntry? DefaultFor(IEnumerable? list, SessionKind kind) + { + var applicable = ApplicableTo(list, kind).ToList(); + return applicable.FirstOrDefault(e => e.IsDefault) ?? applicable.FirstOrDefault(); + } + + /// + /// What a click on for this session would run, or why it can't — + /// for menu tooltips, so a friendly label can't hide the program or its arguments. + /// + public static string Describe(OpenWithEntry entry, OpenWithContext ctx) + { + var (cmd, error) = Build(entry, ctx); + return cmd != null ? cmd.Display : $"{ShellSession.QuoteForCmd(entry.Executable)} — {error}"; + } + + /// + /// The whole list, spelled out for the import confirmation: each entry's program and the + /// literal argument template for every kind it applies to. + /// + public static string DescribeForReview(IEnumerable list) + { + var sb = new StringBuilder(); + foreach (var e in list) + { + sb.Append(e.IsDefault ? "★ " : "• ").Append(e.Label).Append('\n'); + foreach (var kind in new[] { SessionKind.Local, SessionKind.Wsl, SessionKind.Ssh }) + { + string? t = e.ArgsFor(kind); + if (string.IsNullOrWhiteSpace(t)) continue; + sb.Append(" ").Append(KindName(kind)).Append(": ") + .Append(ShellSession.QuoteForCmd(e.Executable)).Append(' ').Append(t).Append('\n'); + } + } + return sb.ToString(); + } + + /// True when two lists would launch the same things (labels, programs, templates, default, order). + public static bool SameCommands(IReadOnlyList a, IReadOnlyList b) => + a.Count == b.Count && a.Zip(b).All(p => + p.First.Label == p.Second.Label + && string.Equals(p.First.Executable, p.Second.Executable, StringComparison.OrdinalIgnoreCase) + && p.First.LocalArgs == p.Second.LocalArgs && p.First.WslArgs == p.Second.WslArgs + && p.First.SshArgs == p.Second.SshArgs && p.First.IsDefault == p.Second.IsDefault); + + // ── Sanitize (state.json / import) ──────────────────────────────────────────────────── + + /// + /// Returns a cleaned copy of a list that came from disk: entries with an unacceptable + /// program are dropped; templates that don't validate are cleared (and an entry left with + /// none is dropped); labels and ids are filled in; exactly one default. state.json passes + /// = true (the user may have chosen a batch file in the + /// editor); an import passes false — see "Open with…" in CLAUDE.md. Pure. + /// + public static List Sanitize(IEnumerable? list, bool allowBatch, Action? log = null) => + SanitizeCore(list, e => ValidateExecutable(e, allowBatch), log); + + /// plus per program (no batch files). Touches the disk. + public static List SanitizeImported(IEnumerable? list, Action? log = null) => + SanitizeCore(list, e => ValidateExecutableOnDisk(e, allowBatch: false), log); + + private static List SanitizeCore(IEnumerable? list, Func validateExe, Action? log) + { + var result = new List(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var raw in list ?? []) + { + if (raw == null) continue; + if (result.Count >= MaxEntries) { log?.Invoke($"dropped entries beyond {MaxEntries}"); break; } + var e = raw.Clone(); + e.Executable = e.Executable?.Trim() ?? ""; + string? exeError = validateExe(e.Executable); + if (exeError != null) + { + log?.Invoke($"dropped '{e.Label}' ({e.Executable}): {exeError}"); + continue; + } + e.LocalArgs = CleanTemplate(e.LocalArgs, SessionKind.Local, e, log); + e.WslArgs = CleanTemplate(e.WslArgs, SessionKind.Wsl, e, log); + e.SshArgs = CleanTemplate(e.SshArgs, SessionKind.Ssh, e, log); + if (e.LocalArgs == null && e.WslArgs == null && e.SshArgs == null) + { + log?.Invoke($"dropped '{e.Label}' ({e.Executable}): no usable arguments for any session kind"); + continue; + } + e.Label = string.IsNullOrWhiteSpace(e.Label) + ? Path.GetFileNameWithoutExtension(e.Executable) + : new string(e.Label.Trim().Where(c => !char.IsControl(c)).Take(80).ToArray()); + if (string.IsNullOrWhiteSpace(e.Id) || !ids.Add(e.Id)) + { + e.Id = Guid.NewGuid().ToString(); + ids.Add(e.Id); + } + result.Add(e); + } + OpenWithEntry.EnsureSingleDefault(result); + return result; + } + + private static string? CleanTemplate(string? template, SessionKind kind, OpenWithEntry e, Action? log) + { + if (string.IsNullOrWhiteSpace(template)) return null; + string? err = ValidateTemplate(template, kind); + if (err == null) return template.Trim(); + log?.Invoke($"'{e.Label}': cleared {KindName(kind)} arguments: {err}"); + return null; + } + + // ── Shims ───────────────────────────────────────────────────────────────────────────── + + /// + /// The program behind a launcher script. Known VS Code-family shims (code.cmd, + /// code-insiders.cmd, cursor.cmd, windsurf.cmd, codium.cmd) map + /// to their Electron exe a few folders up. Any other batch file is scanned for quoted + /// absolute paths to an existing, acceptable .exe — and resolves only when there is + /// exactly ONE distinct candidate (JetBrains Toolbox scripts have that shape); a script that + /// names several programs is ambiguous and returns null rather than a guess. A non-batch + /// path is returned unchanged. Touches the disk. + /// + public static string? ResolveShim(string path) + { + if (!IsBatch(path)) return path; + try + { + string name = Path.GetFileNameWithoutExtension(path); + string? known = name.ToLowerInvariant() switch + { + "code" => "Code.exe", + "code-insiders" => "Code - Insiders.exe", + "cursor" => "Cursor.exe", + "windsurf" => "Windsurf.exe", + "codium" => "VSCodium.exe", + _ => null, + }; + if (known != null) + { + string? dir = Path.GetDirectoryName(Path.GetFullPath(path)); + for (int i = 0; i < 4 && dir != null; i++) + { + string candidate = Path.Combine(dir, known); + if (File.Exists(candidate)) return candidate; + dir = Path.GetDirectoryName(dir); + } + } + + var info = new FileInfo(path); + if (!info.Exists || info.Length > 64 * 1024) return null; + var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match m in Regex.Matches(File.ReadAllText(path), "\"([A-Za-z]:\\\\[^\"\\r\\n]+?\\.exe)\"", RegexOptions.IgnoreCase)) + { + string exe = m.Groups[1].Value; + if (ValidateExecutable(exe, allowBatch: false) == null && File.Exists(exe)) + candidates.Add(Path.GetFullPath(exe)); + } + return candidates.Count == 1 ? candidates.First() : null; + } + catch { } + return null; + } + + // ── Launch ──────────────────────────────────────────────────────────────────────────── + + /// + /// Opens the session's folder in . Runs entirely on the thread + /// pool (disk checks, a possible WSL $HOME probe, Process.Start) — never + /// call the inner work on the UI thread. Returns null on success, else a message for the + /// user. Never throws. + /// + public static Task LaunchAsync(OpenWithEntry entry, OpenWithContext ctx) => Task.Run(async () => + { + try + { + var prepared = await PrepareAsync(entry, ctx).ConfigureAwait(false); + if (prepared.Error != null) return prepared.Error; + Process.Start(prepared.Command!.ToStartInfo())?.Dispose(); + return (string?)null; + } + catch (Exception ex) + { + return $"Couldn't start {entry.Label}: {ex.Message}"; + } + }); + + /// + /// Everything does short of starting the process. Order matters: + /// everything that can be decided without side effects (program shape, template, values) + /// is validated FIRST, then the program on disk, and only then is a WSL $HOME probed + /// — never spawn wsl.exe for a command that was going to be refused anyway. Touches the disk. + /// + internal static async Task<(OpenWithCommand? Command, string? Error)> PrepareAsync( + OpenWithEntry entry, OpenWithContext ctx, Func>? homeProbe = null) + { + string? template = entry.ArgsFor(ctx.Kind); + bool usesFolder = template?.Contains("{folder", StringComparison.Ordinal) == true; + bool needsHome = false; + string lp = ctx.LinuxPath?.Trim() ?? ""; + if (ctx.Kind == SessionKind.Wsl && template?.Contains("{linuxPath", StringComparison.Ordinal) == true) + needsHome = lp.Length == 0 || lp == "~" || lp.StartsWith("~/", StringComparison.Ordinal); + + // 1. Pure validation, with a stand-in for the not-yet-resolved home folder. + var probeCtx = needsHome ? ctx with { LinuxPath = "/home/stand-in" + (lp.Length > 1 ? lp[1..] : "") } : ctx; + var dry = Build(entry, probeCtx); + if (dry.Error != null) return dry; + + // 2. The program on disk (long path, links, network drive, renamed shell). + string? diskError = ValidateExecutableOnDisk(entry.Executable, allowBatch: true); + if (diskError != null) return (null, diskError); + if (!File.Exists(entry.Executable)) + return (null, $"{entry.Label}: program not found at {entry.Executable}. Edit the entry under Open with → Edit…"); + + // 3. Only now the side-effecting probe: a blank Linux folder means $HOME. + if (needsHome) + { + homeProbe ??= WslDiscoveryService.GetDistroHomeAsync; + string? home = await homeProbe(ctx.Distro, ctx.WslUser).ConfigureAwait(false); + ctx = ctx with { LinuxPath = home == null ? "" : lp.Length <= 1 ? home : home.TrimEnd('/') + lp[1..] }; + } + + // 4. The folder must be an existing directory (same check as Open in Explorer). + if (usesFolder) + { + string? target = FolderOpener.Target(ctx.Folder); + if (target == null) return (null, $"The session's folder doesn't exist: {ctx.Folder}"); + ctx = ctx with { Folder = target }; + } + + return Build(entry, ctx); + } + + /// Appends a line to crash.log. Never throws. + public static void Log(string message) + { + try + { + string path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "CodeShellManager", "crash.log"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.AppendAllText(path, $"[{DateTime.Now:HH:mm:ss.fff}] OpenWith: {message}\n"); + } + catch { /* logger failure is not actionable */ } + } +} diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index 2b8e28b..64a1185 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -103,6 +103,11 @@ internal static AppState Normalize(AppState s) // deserialisation-order tricks. Import goes through this too (ImportExportService). foreach (var session in s.Sessions) session.MigrateLegacyFields(); foreach (var entry in s.RecentlyClosed) entry.MigrateLegacyFields(); + // "Open with" entries launch a program on a click, and state.json is not trusted + // input. Batch files are allowed here because the user may have chosen one in the + // editor (launch then refuses cmd metacharacters); an import is stricter — see + // ImportExportService. + s.Settings.OpenWith = OpenWithService.Sanitize(s.Settings.OpenWith, allowBatch: true, OpenWithService.Log); return s; } diff --git a/src/CodeShellManager/Views/OpenWithDialog.xaml b/src/CodeShellManager/Views/OpenWithDialog.xaml new file mode 100644 index 0000000..af7b7ec --- /dev/null +++ b/src/CodeShellManager/Views/OpenWithDialog.xaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + +