From 2a0e187a788159e9eebd7cf14e92324a8e904f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 16:51:24 +0100 Subject: [PATCH 1/2] feat(open-with): configurable editors/IDEs instead of a hard-wired "Open in VS Code" (#178) Replace the single VS Code action with a user-owned "Open with" list: label, program, and an argument template per session kind (local {folder}; WSL {distro} {linuxPath} {folder}; SSH {user} {host} {userAtHost} {port} {remoteFolder}), one default. - Toolbar { } becomes the run-button pattern: { } opens the default for the session's kind, the chevron lists every applicable entry plus Edit...; sidebar right-click gains "Open with >". Works for WSL and SSH sessions (VS Code family via --remote wsl+ / ssh-remote+). - Seeded once by OpenWithDetector (off the UI thread): VS Code stable and Insiders, Cursor, Windsurf, VSCodium (PATH shim -> exe, App Paths, standard install dirs, Scoop), Visual Studio via vswhere, JetBrains IDEs (standalone + Toolbox), Zed, Sublime Text, Notepad++. PATH code.cmd is still found first, so the zero-config default is unchanged. - OpenWithService (WPF-free): templates are tokenised before substitution, so each placeholder is one argv element; launch is UseShellExecute=false + ArgumentList. Programs must be absolute local .exe paths and never a shell; .cmd/.bat shims are resolved to the real exe, and a batch target refuses any argument with cmd metacharacters (BatBadBut). Option-shaped and control-character values are refused; a local folder must exist. - state.json and imports are sanitised; imports reject batch files outright. The editor (Settings -> Open with, or Edit...) previews the full resolved command for each kind. - 110 new unit tests (hostile folder names round-tripped through CommandLineToArgvW, BatBadBut refusal, import trust, detector layouts). Closes #178 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 65 ++- README.md | 2 +- src/CodeShellManager/MainWindow.OpenIn.cs | 234 +++++--- src/CodeShellManager/MainWindow.xaml.cs | 36 +- src/CodeShellManager/Models/AppState.cs | 9 + src/CodeShellManager/Models/OpenWithEntry.cs | 65 +++ .../Services/ImportExportService.cs | 9 +- .../Services/OpenWithDetector.cs | 338 ++++++++++++ .../Services/OpenWithService.cs | 522 ++++++++++++++++++ src/CodeShellManager/Services/StateService.cs | 5 + .../Views/OpenWithDialog.xaml | 173 ++++++ .../Views/OpenWithDialog.xaml.cs | 298 ++++++++++ .../Views/SettingsWindow.xaml | 19 + .../Views/SettingsWindow.xaml.cs | 29 + .../OpenWithDetectorTests.cs | 186 +++++++ .../OpenWithServiceTests.cs | 485 ++++++++++++++++ 16 files changed, 2389 insertions(+), 86 deletions(-) create mode 100644 src/CodeShellManager/Models/OpenWithEntry.cs create mode 100644 src/CodeShellManager/Services/OpenWithDetector.cs create mode 100644 src/CodeShellManager/Services/OpenWithService.cs create mode 100644 src/CodeShellManager/Views/OpenWithDialog.xaml create mode 100644 src/CodeShellManager/Views/OpenWithDialog.xaml.cs create mode 100644 tests/CodeShellManager.Tests/OpenWithDetectorTests.cs create mode 100644 tests/CodeShellManager.Tests/OpenWithServiceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index fa7b0be..2787b49 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,60 @@ 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` / `OpenWithDetectorTests`). + +**Model.** `AppSettings.OpenWith: List { Label, Executable, LocalArgs, WslArgs, +SshArgs, IsDefault }`. A blank template hides the entry for that kind. 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. + +**Seeded once, then the user's.** `OpenWithDetector` runs on the first launch whose state has +`OpenWithSeeded == false` (every state.json from before #178) — gated on the flag, not on an +empty list, so 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 `--remote wsl+{distro} {linuxPath}` and `--remote ssh-remote+{userAtHost} {remoteFolder}`), +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. `OpenWithServiceTests` round-trips hostile folder names through + `CommandLineToArgvW`. +- **Values are untrusted** (state.json, imports): control characters are refused; a value + starting with `-` is refused (the editor would read it as an option); 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 SSH session with no remote folder opens the host. +- **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`). If the + user insists on one, every argument containing a cmd metacharacter (`%!&|<>^"(),;=`) is + **refused, never escaped**. +- **Programs:** an absolute local path to an `.exe` (no relative, UNC or device path), and never + a shell or script host (`cmd`, `powershell`, `pwsh`, `wsl`, `bash`, `mshta`, `rundll32`, `wt`, + …) — an "editor" that is really a shell turns a template into command execution. + +**Import trust.** `StateService.Normalize` sanitises the list on every load +(`OpenWithService.Sanitize`: drops bad programs, clears bad templates, one default, fresh ids); +`ImportExportService` re-sanitises with batch files disallowed, since a backup is any JSON file +the user points at. A click is the only trigger (unlike `PostRunUrl`), and the editor shows the +full resolved command line for every kind, so an imported entry hides nothing. Importing a +pre-#178 backup (`OpenWithSeeded` false) keeps the current list. + ## 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..b290144 100644 --- a/src/CodeShellManager/MainWindow.OpenIn.cs +++ b/src/CodeShellManager/MainWindow.OpenIn.cs @@ -1,119 +1,221 @@ 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) + { + _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; }; + + 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); + + _openWithControls[vm.Id] = (host, btn); + ApplyOpenWithButtonState(vm, host, btn); + return host; + } + + 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; + host.Visibility = entry == null ? Visibility.Collapsed : Visibility.Visible; + btn.ToolTip = entry == null ? null : $"Open folder in {entry.Label} (right-click: edit list)"; + } - if (CanOpenInEditor(vm)) + /// Repaints every pane's { } after the list changed (seeded, edited, imported). + private void RefreshOpenWithButtons() + { + foreach (var id in _openWithControls.Keys.ToList()) { - _ = VsCodeLookup().ContinueWith(t => - { - if (t.IsCompletedSuccessfully && t.Result != null) - Dispatcher.BeginInvoke(() => btn.Visibility = Visibility.Visible); - }, TaskScheduler.Default); + var vm = _vm.Sessions.FirstOrDefault(s => s.Id == id); + if (vm == null) { _openWithControls.Remove(id); continue; } + var (host, btn) = _openWithControls[id]; + ApplyOpenWithButtonState(vm, host, 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; + } - private void OpenInVsCode(string folder) + /// "Open with ▸" for the sidebar right-click menu. Null when the session has nothing to open. + private MenuItem? BuildOpenWithSubmenu(SessionViewModel vm) { - _ = Task.Run(async () => + 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; + } + + /// + /// 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) + { + var items = new List(); + foreach (var entry in OpenWithService.ApplicableTo(_vm.Settings.OpenWith, vm.Session.Kind)) { - try + 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, + ToolTip = entry.Executable, + }; + 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..c811e0e 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 @@ -3466,13 +3467,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 +3475,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 +4532,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 +4670,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 +4686,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); @@ -6013,6 +6013,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 +6145,14 @@ private async void Import_Click(object sender, RoutedEventArgs e) _vm.Settings.MaxSearchResults = s.MaxSearchResults; _vm.Settings.ShowTerminalStatusDot = s.ShowTerminalStatusDot; _vm.Settings.LaunchCommands = s.LaunchCommands; + // Already sanitized by ImportExportService (no batch files, no shells). A backup + // from before #178 carries no list — keep ours rather than wipe it. + if (s.OpenWithSeeded) + { + _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..13f11bf 100644 --- a/src/CodeShellManager/Services/ImportExportService.cs +++ b/src/CodeShellManager/Services/ImportExportService.cs @@ -31,7 +31,14 @@ 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 at all. + // The settings editor shows each entry's full resolved command, so nothing kept + // here is hidden from the user. + state.Settings.OpenWith = OpenWithService.Sanitize(state.Settings.OpenWith, allowBatch: false, 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..ae0d37f --- /dev/null +++ b/src/CodeShellManager/Services/OpenWithDetector.cs @@ -0,0 +1,338 @@ +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. + public const string VsCodeLocalArgs = "{folder}"; + public const string VsCodeWslArgs = "--remote wsl+{distro} {linuxPath}"; + public const string VsCodeSshArgs = "--remote ssh-remote+{userAtHost} {remoteFolder}"; + + 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..6cf101a --- /dev/null +++ b/src/CodeShellManager/Services/OpenWithService.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CodeShellManager.Models; + +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 as CreateProcess will see it — for the editor preview and error messages. Never executed. + 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. +/// +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"], + }; + + /// + /// 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 as code. An "editor" that is really a shell turns a template + /// into a command-execution primitive, and the list arrives from imports. Checked by file + /// name, case-insensitively. + /// + 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", + }; + + private static readonly Regex PlaceholderRegex = new(@"\{([A-Za-z]+)\}", 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); + } + + /// + /// Null when is acceptable as an entry's program, else the reason. + /// Pure — does not check that the file exists (that happens at launch). Rules: an absolute + /// local path (no relative path, so nothing is resolved against PATH or the cwd; no UNC, so + /// nothing runs from a network share), a .exe — or a .cmd/.bat only when + /// — and not a 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."; + + string ext = Path.GetExtension(path); + 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 (!ext.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + return "The program must be an .exe."; + } + + 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; + } + + // ── 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). + 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]; + foreach (var token in tokens) + { + foreach (Match m in PlaceholderRegex.Matches(token)) + { + 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 + "}"))})."; + } + } + 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 "": an SSH session with no remote folder opens the host, + // not a folder called "". + 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? valueError = ValidateValue(name, values[name]); + if (valueError != null) return (null, valueError); + } + args.Add(PlaceholderRegex.Replace(token, m => values[m.Groups[1].Value])); + } + + 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 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. Applies + // anywhere in a token, which is stricter than needed but 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": + 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 ───────────────────────────────────────────────────────────────────────── + + 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(); + } + + // ── 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. + /// + public static List Sanitize(IEnumerable? list, bool allowBatch, Action? log = null) + { + 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 = ValidateExecutable(e.Executable, allowBatch); + 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 a quoted + /// absolute path to an existing .exe (JetBrains Toolbox scripts have that shape). + /// A non-batch path is returned unchanged. Null when nothing was found. 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; + 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)) return exe; + } + } + 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: resolves a + /// WSL ~, checks the folder is an existing directory (only when the template uses + /// {folder}), checks the program file exists, and builds the argv. Touches the disk. + /// + internal static async Task<(OpenWithCommand? Command, string? Error)> PrepareAsync(OpenWithEntry entry, OpenWithContext ctx) + { + string? template = entry.ArgsFor(ctx.Kind); + bool usesFolder = template?.Contains("{folder}", StringComparison.Ordinal) == true; + + if (ctx.Kind == SessionKind.Wsl && template?.Contains("{linuxPath}", StringComparison.Ordinal) == true) + { + // A blank Linux folder means $HOME (see "WSL Sessions"); editors want a real path. + string lp = ctx.LinuxPath?.Trim() ?? ""; + if (lp.Length == 0 || lp == "~" || lp.StartsWith("~/", StringComparison.Ordinal)) + { + string? home = await WslDiscoveryService.GetDistroHomeAsync(ctx.Distro, ctx.WslUser).ConfigureAwait(false); + if (home != null) + ctx = ctx with { LinuxPath = lp.Length <= 1 ? home : home.TrimEnd('/') + lp[1..] }; + else if (lp.Length > 0) + ctx = ctx with { LinuxPath = "" }; // leave it to the editor's default rather than pass a literal ~ + } + } + + if (usesFolder) + { + // Same check as Open in Explorer: the folder comes from state.json and imports, + // so it must be an existing directory, normalised to a Windows path. + 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 }; + } + + var built = Build(entry, ctx); + if (built.Error != null) return built; + if (!File.Exists(entry.Executable)) + return (null, $"{entry.Label}: program not found at {entry.Executable}. Edit the entry under Open with → Edit…"); + return built; + } + + /// 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..b3ea091 --- /dev/null +++ b/src/CodeShellManager/Views/OpenWithDialog.xaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + +