From 345900fe973bee73643a0d91730dfc439cdc298a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 10:42:13 +0100 Subject: [PATCH] refactor(ui): move "Open in VS Code" out of the git menu, next to Explorer / PowerShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a folder in an editor is not a git action, and it should work for a folder that isn't a repo. It now lives in MainWindow.OpenIn.cs: - a "{ }" button on the terminal toolbar between >_ and πŸ“, and - "Open in VS Code" after "Open PowerShell here" in the sidebar right-click menu. Both appear only when VS Code was found and the session has a local folder (not SSH). The toolbar button starts collapsed and shows once the off-thread lookup succeeds. The lookup and launch are unchanged: code.cmd on PATH β†’ Code.exe, launched directly with the folder as one argv element. A configurable "Open with…" for other editors, other VS Code installs and WSL/SSH remotes is #178. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 14 ++- src/CodeShellManager/MainWindow.Git.cs | 48 --------- src/CodeShellManager/MainWindow.OpenIn.cs | 119 ++++++++++++++++++++++ src/CodeShellManager/MainWindow.xaml.cs | 12 +++ 4 files changed, 142 insertions(+), 51 deletions(-) create mode 100644 src/CodeShellManager/MainWindow.OpenIn.cs diff --git a/CLAUDE.md b/CLAUDE.md index dedd738..7b6bdc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -738,9 +738,17 @@ When a session's folder is a git repo, its terminal toolbar shows a chip β€” ` (branch, peach dirty dot, blue ahead/behind) β€” just left of β–Ά. Clicking it opens a menu: a header line (`main β†’ origin/main Β· ↑2 ↓5 Β· 3 changed`), **Fetch**, **Pull (fast-forward only)**, **Status…**, **Switch branch β–Έ** (local branches, loaded when the submenu opens), -**Copy branch name**, and **Open in VS Code** when `code.cmd` is on PATH. The UI is in -`MainWindow.Git.cs`; git calls are in `GitService`. Hidden for SSH sessions β€” an OSC 9001 -branch is display-only, there is no local folder to run git in. +and **Copy branch name**. The UI is in `MainWindow.Git.cs`; git calls are in `GitService`. +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. **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 diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs index dcf73f7..31d8da2 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -50,7 +50,6 @@ private sealed class GitPaneUi /// private readonly HashSet _gitBusyRepos = new(StringComparer.OrdinalIgnoreCase); - private static Task? _vsCodeLookup; private const int MaxChipBranchChars = 32; private static readonly SolidColorBrush GitMuted = Frozen(0xa6, 0xad, 0xc8); @@ -75,8 +74,6 @@ private static string GitRepoKey(SessionViewModel vm) => /// private (WpfButton chip, Border panel) BuildGitControls(SessionViewModel vm) { - _vsCodeLookup ??= Task.Run(FindVsCode); - var chipText = new TextBlock { VerticalAlignment = VerticalAlignment.Center, FontSize = 11 }; var chip = new WpfButton { @@ -279,28 +276,6 @@ private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) copy.Click += (_, _) => { try { Clipboard.SetText(vm.GitBranch ?? ""); } catch { } }; menu.Items.Add(copy); - if (_vsCodeLookup is { IsCompletedSuccessfully: true, Result: { } codeExe }) - { - var code = new MenuItem { Header = "Open in VS Code" }; - string folder = vm.WorkingFolder; - code.Click += (_, _) => _ = Task.Run(() => - { - try - { - // Code.exe directly with the folder as one argv element β€” not code.cmd, - // which would put the folder through cmd.exe's parser. - var psi = new System.Diagnostics.ProcessStartInfo(codeExe) { UseShellExecute = false }; - psi.ArgumentList.Add(folder); - System.Diagnostics.Process.Start(psi)?.Dispose(); - } - catch (Exception ex) - { - Dispatcher.BeginInvoke(() => ToastHelper.Show("Couldn't open VS Code", ex.Message)); - } - }); - menu.Items.Add(code); - } - menu.IsOpen = true; } @@ -388,27 +363,4 @@ private void ShowGitPanel(SessionViewModel vm, string header, string body) ui.PanelText.ScrollToHome(); ui.Panel.Visibility = Visibility.Visible; } - - /// - /// 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. - /// - private static string? FindVsCode() - { - try - { - 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 = System.IO.Path.Combine(d, "code.cmd"); - if (!System.IO.File.Exists(cmd)) continue; - string exe = System.IO.Path.GetFullPath(System.IO.Path.Combine(d, "..", "Code.exe")); - if (System.IO.File.Exists(exe)) return exe; - } - } - catch { } - return null; - } } diff --git a/src/CodeShellManager/MainWindow.OpenIn.cs b/src/CodeShellManager/MainWindow.OpenIn.cs new file mode 100644 index 0000000..d22db84 --- /dev/null +++ b/src/CodeShellManager/MainWindow.OpenIn.cs @@ -0,0 +1,119 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +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; + +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. +/// +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; + + private static Task VsCodeLookup() => _vsCodeLookup ??= Task.Run(FindVsCode); + + /// 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); + + /// + /// 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. + /// + private WpfButton BuildVsCodeButton(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), + Margin = new Thickness(0, 0, 4, 0), + Visibility = Visibility.Collapsed, + }; + btn.Click += (_, _) => OpenInVsCode(vm.WorkingFolder); + + if (CanOpenInEditor(vm)) + { + _ = VsCodeLookup().ContinueWith(t => + { + if (t.IsCompletedSuccessfully && t.Result != null) + Dispatcher.BeginInvoke(() => btn.Visibility = Visibility.Visible); + }, TaskScheduler.Default); + } + 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 OpenInVsCode(string folder) + { + _ = Task.Run(async () => + { + try + { + 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) + { + _ = Dispatcher.BeginInvoke(() => ToastHelper.Show("Couldn't open VS Code", ex.Message)); + } + }); + } + + /// + /// 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. + /// + private static string? FindVsCode() + { + try + { + 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; + } + } + catch { } + return null; + } +} diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 994b78a..6bbc0fd 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -3464,6 +3464,13 @@ 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" }; @@ -4524,6 +4531,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); + // PowerShell quick-launch button var toolbarPsBtn = new WpfButton { @@ -4658,6 +4668,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(toolbarPsBtn, Dock.Right); DockPanel.SetDock(notesBtn, Dock.Right); DockPanel.SetDock(sleepBtn, Dock.Right); @@ -4673,6 +4684,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(toolbarPsBtn); toolbarContent.Children.Add(notesBtn); toolbarContent.Children.Add(sleepBtn);