From 7b22e08a712219b156a018b9d258710f16081623 Mon Sep 17 00:00:00 2001 From: gitaman69 Date: Fri, 10 Jul 2026 18:40:08 +0530 Subject: [PATCH] feat(gtk): add Vaults view and macOS-style single-row tab bar Fixed Vaults/SFTP buttons with tabs to their right, single-click host connect, subtree-aware group filtering, violet Sarv theme. --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/application.zig | 97 ++++ src/apprt/gtk/class/sarv_vaults_view.zig | 560 +++++++++++++++++++++ src/apprt/gtk/class/window.zig | 50 +- src/apprt/gtk/ui/1.5/sarv-files-dialog.blp | 2 + src/apprt/gtk/ui/1.5/sarv-vaults-view.blp | 239 +++++++++ src/apprt/gtk/ui/1.5/window.blp | 94 ++-- src/config/Config.zig | 15 +- src/sarv/vault.zig | 36 ++ 9 files changed, 1052 insertions(+), 42 deletions(-) create mode 100644 src/apprt/gtk/class/sarv_vaults_view.zig create mode 100644 src/apprt/gtk/ui/1.5/sarv-vaults-view.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index c64023d8..f2f39e05 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -52,6 +52,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "title-dialog" }, .{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "command-palette" }, + .{ .major = 1, .minor = 5, .name = "sarv-vaults-view" }, .{ .major = 1, .minor = 5, .name = "sarv-hosts-dialog" }, .{ .major = 1, .minor = 5, .name = "sarv-host-editor" }, .{ .major = 1, .minor = 5, .name = "sarv-known-hosts-dialog" }, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 36640af4..80bd1a05 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -45,6 +45,87 @@ const OpenURI = @import("../portal.zig").OpenURI; const log = std.log.scoped(.gtk_ghostty_application); +/// Sarv theme stylesheet — matches the macOS app's palette (sampled from +/// assets/screenshots/hosts.png): violet accent #9A55A3 with rounded card +/// rows and violet icon tiles like the macOS hosts grid. Surfaces that must +/// adapt between light and dark GTK themes use alpha() over theme colors +/// instead of fixed values; only the brand accent is fixed. CSS parse errors +/// are non-fatal (logged via the provider's parsing-error signal). +const sarv_theme_css = + \\@define-color accent_bg_color #9A55A3; + \\@define-color accent_color #C77BD1; + \\@define-color accent_fg_color #ffffff; + \\ + \\/* List rows as rounded cards, like the macOS hosts grid. */ + \\.rich-list > row { + \\ margin: 3px 8px; + \\ padding: 8px 10px; + \\ border-radius: 10px; + \\ border: 1px solid alpha(@window_fg_color, 0.10); + \\ background-color: alpha(@window_fg_color, 0.045); + \\} + \\.rich-list > row:hover { + \\ background-color: alpha(@window_fg_color, 0.09); + \\} + \\.rich-list > row:selected { + \\ background-color: alpha(#9A55A3, 0.28); + \\ border-color: alpha(#C77BD1, 0.45); + \\} + \\ + \\/* Vaults view: card grid + solid violet sidebar pill, like macOS. */ + \\.sarv-card { + \\ padding: 12px; + \\ border-radius: 10px; + \\ border: 1px solid alpha(@window_fg_color, 0.10); + \\ background-color: alpha(@window_fg_color, 0.045); + \\} + \\.sarv-card:hover { + \\ background-color: alpha(@window_fg_color, 0.09); + \\} + \\.sarv-card:selected { + \\ background-color: alpha(#9A55A3, 0.28); + \\ border-color: alpha(#C77BD1, 0.45); + \\} + \\.sarv-vaults-sidebar { + \\ padding: 6px; + \\} + \\.sarv-vaults-sidebar row { + \\ padding: 8px 10px; + \\ border-radius: 8px; + \\} + \\.sarv-vaults-sidebar row:selected { + \\ background-color: #9A55A3; + \\ color: #ffffff; + \\} + \\ + \\/* Rounded-square icon tiles, like the macOS host cards. */ + \\.sarv-host-icon { + \\ background-color: #9A55A3; + \\ color: #ffffff; + \\ border-radius: 8px; + \\ padding: 7px; + \\} + \\ + \\/* File-browser glyphs are violet-tinted with no tile, and the file + \\ * panes stay flat tables (macOS SFTP look), opting out of card rows. */ + \\.sarv-file-icon { + \\ color: #B87DC0; + \\} + \\.rich-list.sarv-flat-list > row { + \\ margin: 0; + \\ padding: 6px 10px; + \\ border: none; + \\ border-radius: 0; + \\ background-color: transparent; + \\} + \\.rich-list.sarv-flat-list > row:hover { + \\ background-color: alpha(@window_fg_color, 0.06); + \\} + \\.rich-list.sarv-flat-list > row:selected { + \\ background-color: alpha(#9A55A3, 0.28); + \\} +; + /// Function used to funnel GLib/GObject/GTK log messages into Zig's logging /// system rather than just getting dumped directly to stderr. fn glibLogWriterFunction( @@ -377,6 +458,22 @@ pub const Application = extern struct { ); errdefer css_provider.unref(); + // Sarv theme: match the GTK dialogs to the macOS app's look (violet + // accent + card rows). Loaded at a priority below the runtime config + // provider so user CSS still wins. Lives for the whole process (the + // Application is a singleton), so it isn't unref'd. + { + const sarv_bytes = glib.Bytes.new(sarv_theme_css.ptr, sarv_theme_css.len); + defer sarv_bytes.unref(); + const sarv_provider = gtk.CssProvider.new(); + sarv_provider.loadFromBytes(sarv_bytes); + gtk.StyleContext.addProviderForDisplay( + display, + sarv_provider.as(gtk.StyleProvider), + gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 2, + ); + } + // Initialize the app. const self = gobject.ext.newInstance(Self, .{ .application_id = app_id.ptr, diff --git a/src/apprt/gtk/class/sarv_vaults_view.zig b/src/apprt/gtk/class/sarv_vaults_view.zig new file mode 100644 index 00000000..37ffe662 --- /dev/null +++ b/src/apprt/gtk/class/sarv_vaults_view.zig @@ -0,0 +1,560 @@ +//! The Sarv "Vaults" view for the GTK app: the macOS main-window layout, +//! embedded in the window as a mode the user toggles into (not a dialog) — +//! a navigation sidebar (Hosts, Keychain, Port Forwarding, Snippets, Known +//! Hosts), a quick-connect search bar and a card grid of groups and hosts. +//! Activating a host card opens an SSH session in a new tab and switches the +//! window back to the terminal. +//! +//! The Hosts page is native to this view; the other sidebar sections open +//! their existing dialogs until they are ported into pages here. + +const std = @import("std"); +const ArenaAllocator = std.heap.ArenaAllocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../../config.zig"); +const sarv = @import("../../../sarv/main.zig"); +const gresource = @import("../build/gresource.zig"); +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Window = @import("window.zig").Window; +const SarvHostEditor = @import("sarv_host_editor.zig").SarvHostEditor; +const SarvKeysDialog = @import("sarv_keys_dialog.zig").SarvKeysDialog; +const SarvTunnelsDialog = @import("sarv_tunnels_dialog.zig").SarvTunnelsDialog; +const SarvSnippetsDialog = @import("sarv_snippets_dialog.zig").SarvSnippetsDialog; +const SarvKnownHostsDialog = @import("sarv_known_hosts_dialog.zig").SarvKnownHostsDialog; +const SarvFilesDialog = @import("sarv_files_dialog.zig").SarvFilesDialog; +const SarvSyncDialog = @import("sarv_sync_dialog.zig").SarvSyncDialog; + +const log = std.log.scoped(.gtk_sarv_vaults_view); + +/// Sidebar row order; must match the ListBoxRow order in the blueprint. +const Section = enum(c_int) { + hosts = 0, + keychain = 1, + port_forwarding = 2, + snippets = 3, + known_hosts = 4, +}; + +pub const SarvVaultsView = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySarvVaultsView", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + /// One host card in the grid: the id/search text needed to filter and + /// connect. Strings live in the reload arena. + const Card = struct { + id: [:0]const u8, + search: []const u8, + /// The host's group id plus every ancestor group id (leaf → root), + /// so an active group filter also matches hosts in its subgroups. + group_chain: []const []const u8, + child: *gtk.FlowBoxChild, + }; + + /// One group card: the group id it filters by. + const GroupCard = struct { + id: [:0]const u8, + child: *gtk.FlowBoxChild, + }; + + const Private = struct { + /// The window this view opens tabs into. + window: WeakRef(Window) = .empty, + root: *gtk.Box, + sidebar: *gtk.ListBox, + search: *gtk.SearchEntry, + groups_flow: *gtk.FlowBox, + hosts_flow: *gtk.FlowBox, + groups_header: *gtk.Label, + hosts_header: *gtk.Label, + empty_label: *gtk.Label, + + /// Backing store for the current card grid; reset on every reload. + arena: ?ArenaAllocator = null, + cards: std.ArrayListUnmanaged(Card) = .empty, + group_cards: std.ArrayListUnmanaged(GroupCard) = .empty, + + /// When set, only hosts in this group are shown (a group card is + /// active). Points into the reload arena. + active_group: ?[]const u8 = null, + + /// Guards sidebar_selected so programmatic re-selection (bouncing + /// back to Hosts after opening a section dialog) doesn't recurse. + reselecting: bool = false, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + // The template root is a free-floating top-level object; adopt it + // as this Bin's child so the view renders wherever it is placed. + self.as(adw.Bin).setChild(self.private().root.as(gtk.Widget)); + } + + /// Set the window this view opens tabs into. Called once by the window + /// after its template initializes. + pub fn setWindow(self: *Self, window: *Window) void { + self.private().window.set(window); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + self.clearGrid(); + if (priv.arena) |*arena| { + arena.deinit(); + priv.arena = null; + } + gtk.Widget.disposeTemplate(self.as(gtk.Widget), getGObjectType()); + gobject.Object.virtual_methods.dispose.call(Class.parent, self.as(Parent)); + } + + /// (Re)load the vault and reset the view; called each time the window + /// switches into Vaults mode. + pub fn refresh(self: *Self) void { + const priv = self.private(); + self.reload(); + self.selectHostsRow(); + _ = priv.search.as(gtk.Widget).grabFocus(); + } + + /// Remove every card from both flow boxes. The card slice itself is + /// owned by the arena and freed on the next reload/dispose. + fn clearGrid(self: *Self) void { + const priv = self.private(); + while (priv.groups_flow.as(gtk.Widget).getFirstChild()) |child| { + priv.groups_flow.remove(child); + } + while (priv.hosts_flow.as(gtk.Widget).getFirstChild()) |child| { + priv.hosts_flow.remove(child); + } + priv.cards = .empty; + priv.group_cards = .empty; + priv.active_group = null; + } + + /// Load hosts + groups from the vault and rebuild the card grid. + fn reload(self: *Self) void { + const priv = self.private(); + self.clearGrid(); + + if (priv.arena) |*arena| arena.deinit(); + priv.arena = .init(Application.default().allocator()); + const alloc = priv.arena.?.allocator(); + + const gpa = Application.default().allocator(); + var hosts = sarv.vault.loadHosts(gpa) catch |err| { + log.warn("failed to load hosts: {}", .{err}); + self.updateVisibility(0, 0); + return; + }; + defer hosts.deinit(); + var groups = sarv.vault.loadGroups(gpa) catch |err| { + log.warn("failed to load groups: {}", .{err}); + self.updateVisibility(0, 0); + return; + }; + defer groups.deinit(); + + // Group cards, with a live host count per group. The count covers + // the whole subtree so "Production" also counts "Production > Web". + for (groups.items) |*group| { + var count: usize = 0; + for (hosts.items) |*host| { + if (sarv.vault.groupInSubtree(groups.items, host.groupID, group.id)) { + count += 1; + } + } + const subtitle = std.fmt.allocPrintSentinel( + alloc, + "{d} {s}", + .{ count, if (count == 1) "Host" else "Hosts" }, + 0, + ) catch continue; + const name = alloc.dupeZ(u8, group.name) catch continue; + const child = makeCard("folder-symbolic", "sarv-group-icon", name, subtitle, null); + const gid = alloc.dupeZ(u8, group.id) catch continue; + priv.group_cards.append(alloc, .{ .id = gid, .child = child }) catch continue; + priv.groups_flow.append(child.as(gtk.Widget)); + } + + // Host cards. + for (hosts.items) |*host| { + const label = if (host.label.len > 0) host.label else host.hostname; + const title = alloc.dupeZ(u8, label) catch continue; + const subtitle = if (host.username.len > 0) + std.fmt.allocPrintSentinel(alloc, "{s}@{s}", .{ host.username, host.hostname }, 0) catch continue + else + alloc.dupeZ(u8, host.hostname) catch continue; + + const gpath = sarv.vault.groupPath(alloc, groups.items, host.groupID) catch ""; + const chip: ?[:0]const u8 = if (gpath.len > 0) + alloc.dupeZ(u8, gpath) catch null + else + null; + + const child = makeCard("computer-symbolic", "sarv-host-icon", title, subtitle, chip); + const id = alloc.dupeZ(u8, host.id) catch continue; + const search = std.fmt.allocPrint( + alloc, + "{s} {s} {s}", + .{ title, subtitle, gpath }, + ) catch continue; + // Walk parentID links so the chain holds the host's group and + // every ancestor; the groups list is gone by filter time. + var chain: std.ArrayListUnmanaged([]const u8) = .empty; + var current: ?[]const u8 = host.groupID; + var guard: usize = 0; + while (current) |gid| { + guard += 1; + if (guard > 64) break; + const duped = alloc.dupe(u8, gid) catch break; + chain.append(alloc, duped) catch break; + current = for (groups.items) |*g| { + if (std.mem.eql(u8, g.id, gid)) break g.parentID; + } else null; + } + priv.cards.append(alloc, .{ + .id = id, + .search = search, + .group_chain = chain.items, + .child = child, + }) catch continue; + priv.hosts_flow.append(child.as(gtk.Widget)); + } + + self.updateVisibility(groups.items.len, hosts.items.len); + } + + fn updateVisibility(self: *Self, group_count: usize, host_count: usize) void { + const priv = self.private(); + const has_groups = group_count > 0; + priv.groups_header.as(gtk.Widget).setVisible(@intFromBool(has_groups)); + priv.groups_flow.as(gtk.Widget).setVisible(@intFromBool(has_groups)); + priv.empty_label.as(gtk.Widget).setVisible(@intFromBool(host_count == 0)); + } + + /// Build one card widget: an icon tile plus title/subtitle (and an + /// optional group chip), matching the macOS hosts grid. + fn makeCard( + icon_name: [:0]const u8, + icon_class: [:0]const u8, + title: [:0]const u8, + subtitle: [:0]const u8, + chip: ?[:0]const u8, + ) *gtk.FlowBoxChild { + const box = gtk.Box.new(.horizontal, 12); + + const icon = gtk.Image.newFromIconName(icon_name.ptr); + icon.setPixelSize(22); + icon.as(gtk.Widget).addCssClass(icon_class.ptr); + icon.as(gtk.Widget).setValign(.center); + box.append(icon.as(gtk.Widget)); + + const text_box = gtk.Box.new(.vertical, 2); + text_box.as(gtk.Widget).setValign(.center); + text_box.as(gtk.Widget).setHexpand(1); + + const title_label = gtk.Label.new(title.ptr); + title_label.as(gtk.Widget).setHalign(.start); + title_label.setMaxWidthChars(18); + title_label.as(gtk.Widget).addCssClass("title"); + text_box.append(title_label.as(gtk.Widget)); + + const subtitle_label = gtk.Label.new(subtitle.ptr); + subtitle_label.as(gtk.Widget).setHalign(.start); + subtitle_label.setMaxWidthChars(22); + subtitle_label.as(gtk.Widget).addCssClass("subtitle"); + subtitle_label.as(gtk.Widget).addCssClass("monospace"); + text_box.append(subtitle_label.as(gtk.Widget)); + + if (chip) |chip_text| { + const chip_label = gtk.Label.new(chip_text.ptr); + chip_label.as(gtk.Widget).setHalign(.start); + chip_label.setMaxWidthChars(22); + chip_label.as(gtk.Widget).addCssClass("dim-label"); + text_box.append(chip_label.as(gtk.Widget)); + } + + box.append(text_box.as(gtk.Widget)); + + const child = gtk.FlowBoxChild.new(); + child.as(gtk.Widget).addCssClass("sarv-card"); + child.setChild(box.as(gtk.Widget)); + return child; + } + + fn searchChanged(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + self.applyFilter(); + } + + /// Show each host card iff it matches the search text (case-insensitive) + /// and the active group filter, when one is set. + fn applyFilter(self: *Self) void { + const priv = self.private(); + const text_c = priv.search.as(gtk.Editable).getText(); + const text = std.mem.span(text_c); + for (priv.cards.items) |*card| { + const search_ok = text.len == 0 or + std.ascii.indexOfIgnoreCase(card.search, text) != null; + const group_ok = if (priv.active_group) |active| blk: { + for (card.group_chain) |gid| { + if (std.mem.eql(u8, gid, active)) break :blk true; + } + break :blk false; + } else true; + card.child.as(gtk.Widget).setVisible(@intFromBool(search_ok and group_ok)); + } + } + + /// A group card was clicked: filter the host grid to that group, or + /// clear the filter when the active group is clicked again. + fn groupActivated(_: *gtk.FlowBox, child: *gtk.FlowBoxChild, self: *Self) callconv(.c) void { + const priv = self.private(); + const clicked: ?[]const u8 = for (priv.group_cards.items) |*gc| { + if (gc.child == child) break gc.id; + } else null; + const id = clicked orelse return; + + if (priv.active_group) |active| { + if (std.mem.eql(u8, active, id)) { + priv.active_group = null; + priv.groups_flow.unselectChild(child); + self.applyFilter(); + return; + } + } + priv.active_group = id; + self.applyFilter(); + } + + /// The Terminal button: open a plain shell tab and switch the window + /// back to the terminal, like the macOS toolbar. + fn terminalClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const window = self.private().window.get() orelse return; + defer window.unref(); + window.showTerminalMode(); + _ = self.as(gtk.Widget).activateAction("win.new-tab", null); + } + + /// Enter in the search bar / the Connect button: treat the text as an + /// ad-hoc `ssh ` quick connect, like the macOS search bar. + fn quickConnect(_: *gtk.Widget, self: *Self) callconv(.c) void { + const priv = self.private(); + const text_c = priv.search.as(gtk.Editable).getText(); + const text = std.mem.trim(u8, std.mem.span(text_c), " "); + if (text.len == 0) return; + + // If the text matches exactly one visible card, connect to it. + var match: ?*const Card = null; + var visible: usize = 0; + for (priv.cards.items) |*card| { + if (card.child.as(gtk.Widget).getVisible() != 0) { + visible += 1; + match = card; + } + } + if (visible == 1) { + self.connectHostById(match.?.id); + return; + } + + // Otherwise treat it as a raw ssh destination. Reject anything with + // whitespace so the text can't smuggle extra arguments. + if (std.mem.indexOfAny(u8, text, " \t") != null) return; + const gpa = Application.default().allocator(); + var host: sarv.model.SavedHost = .{ .id = "", .hostname = text }; + if (std.mem.indexOfScalar(u8, text, '@')) |at| { + host.username = text[0..at]; + host.hostname = text[at + 1 ..]; + } + host.authMethod = .ask; + const cmd_str = sarv.ssh.command(gpa, &host, false) catch return; + defer gpa.free(cmd_str); + self.openTab(cmd_str, host.hostname); + } + + fn hostActivated(_: *gtk.FlowBox, child: *gtk.FlowBoxChild, self: *Self) callconv(.c) void { + const priv = self.private(); + for (priv.cards.items) |*card| { + if (card.child == child) { + self.connectHostById(card.id); + return; + } + } + } + + fn addClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const window = self.private().window.get() orelse return; + defer window.unref(); + const editor = SarvHostEditor.new(); + defer editor.unref(); + _ = SarvHostEditor.signals.saved.connect(editor, *Self, editorSaved, self, .{}); + editor.presentNew(window.as(gtk.Widget)); + } + + fn editorSaved(_: *SarvHostEditor, self: *Self) callconv(.c) void { + self.reload(); + } + + fn syncClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + self.openSection(SarvSyncDialog); + } + + fn filesClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + self.openSection(SarvFilesDialog); + } + + /// Sidebar navigation. Hosts is this view; the other sections open + /// their existing dialogs, then the selection bounces back to Hosts. + fn sidebarSelected(_: *gtk.ListBox, row_: ?*gtk.ListBoxRow, self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.reselecting) return; + const row = row_ orelse return; + const section: Section = switch (row.getIndex()) { + 0...4 => @enumFromInt(row.getIndex()), + else => return, + }; + switch (section) { + .hosts => {}, + .keychain => self.openSection(SarvKeysDialog), + .port_forwarding => self.openSection(SarvTunnelsDialog), + .snippets => self.openSection(SarvSnippetsDialog), + .known_hosts => self.openSection(SarvKnownHostsDialog), + } + if (section != .hosts) self.selectHostsRow(); + } + + fn openSection(self: *Self, comptime Dialog: type) void { + const window = self.private().window.get() orelse return; + defer window.unref(); + const dialog = Dialog.new(); + defer dialog.unref(); + dialog.present(window); + } + + fn selectHostsRow(self: *Self) void { + const priv = self.private(); + priv.reselecting = true; + defer priv.reselecting = false; + if (priv.sidebar.getRowAtIndex(@intFromEnum(Section.hosts))) |row| { + priv.sidebar.selectRow(row); + } + } + + /// Load the full host by id (so we have the password and every SSH + /// option) and open an SSH tab for it. Mirrors the hosts dialog flow. + fn connectHostById(self: *Self, id: []const u8) void { + const gpa = Application.default().allocator(); + var hosts = sarv.vault.loadHosts(gpa) catch |err| { + log.warn("failed to load host for connect: {}", .{err}); + return; + }; + defer hosts.deinit(); + const host = for (hosts.items) |*h| { + if (std.mem.eql(u8, h.id, id)) break h; + } else return; + + // When a password is stored, feed it to ssh out-of-band via + // SSH_ASKPASS; otherwise a plain command lets ssh use keys/agent. + const cmd_str: []u8 = blk: { + if (host.password.len > 0) { + var env = sarv.askpass.prepare(gpa, host.password) catch |err| { + log.warn("askpass prepare failed, connecting without password: {}", .{err}); + break :blk sarv.ssh.command(gpa, host, false) catch return; + }; + defer env.deinit(); + break :blk sarv.ssh.commandWithEnv(gpa, host, true, env) catch return; + } + break :blk sarv.ssh.command(gpa, host, false) catch return; + }; + defer gpa.free(cmd_str); + + self.openTab(cmd_str, if (host.label.len > 0) host.label else host.hostname); + } + + /// Open a new terminal tab running `cmd_str`, titled `title`, and switch + /// the window back to the terminal. + fn openTab(self: *Self, cmd_str: []const u8, title: []const u8) void { + const window = self.private().window.get() orelse return; + defer window.unref(); + + const gpa = Application.default().allocator(); + const cmd_z = gpa.dupeZ(u8, cmd_str) catch return; + defer gpa.free(cmd_z); + const title_z = gpa.dupeZ(u8, title) catch null; + defer if (title_z) |t| gpa.free(t); + + var command: configpkg.Command = undefined; + command.parseCLI(gpa, cmd_z) catch |err| { + log.warn("failed to parse ssh command: {}", .{err}); + return; + }; + + window.newTabWithCommand(command, title_z); + window.showTerminalMode(); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "sarv-vaults-view", + }), + ); + + class.bindTemplateChildPrivate("root", .{}); + class.bindTemplateChildPrivate("sidebar", .{}); + class.bindTemplateChildPrivate("search", .{}); + class.bindTemplateChildPrivate("groups_flow", .{}); + class.bindTemplateChildPrivate("hosts_flow", .{}); + class.bindTemplateChildPrivate("groups_header", .{}); + class.bindTemplateChildPrivate("hosts_header", .{}); + class.bindTemplateChildPrivate("empty_label", .{}); + + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("group_activated", &groupActivated); + class.bindTemplateCallback("terminal_clicked", &terminalClicked); + class.bindTemplateCallback("quick_connect", &quickConnect); + class.bindTemplateCallback("host_activated", &hostActivated); + class.bindTemplateCallback("add_clicked", &addClicked); + class.bindTemplateCallback("sync_clicked", &syncClicked); + class.bindTemplateCallback("files_clicked", &filesClicked); + class.bindTemplateCallback("sidebar_selected", &sidebarSelected); + + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 7e7084ff..9d8d30f0 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -28,6 +28,7 @@ const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; +const SarvVaultsView = @import("sarv_vaults_view.zig").SarvVaultsView; const SarvHostsDialog = @import("sarv_hosts_dialog.zig").SarvHostsDialog; const SarvKnownHostsDialog = @import("sarv_known_hosts_dialog.zig").SarvKnownHostsDialog; const SarvKeysDialog = @import("sarv_keys_dialog.zig").SarvKeysDialog; @@ -272,6 +273,9 @@ pub const Window = extern struct { tab_view: *adw.TabView, toolbar: *adw.ToolbarView, toast_overlay: *adw.ToastOverlay, + content_stack: *gtk.Stack, + vaults_view: *SarvVaultsView, + vaults_toggle: *gtk.ToggleButton, pub var offset: c_int = 0; }; @@ -383,6 +387,7 @@ pub const Window = extern struct { .init("clear", actionClear, null), // TODO: accept the surface that toggled the command palette .init("toggle-command-palette", actionToggleCommandPalette, null), + .init("show-sarv-vaults", actionShowSarvVaults, null), .init("show-sarv-hosts", actionShowSarvHosts, null), .init("show-sarv-known-hosts", actionShowSarvKnownHosts, null), .init("show-sarv-keys", actionShowSarvKeys, null), @@ -1363,10 +1368,6 @@ pub const Window = extern struct { self.syncAppearance(); } - fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void { - self.performBindingAction(.new_tab); - } - fn tabOverviewCreateTab( _: *adw.TabOverview, self: *Self, @@ -1543,6 +1544,10 @@ pub const Window = extern struct { // If the tab was previously marked as needing attention // (e.g. due to a bell character), we now unmark that page.setNeedsAttention(@intFromBool(false)); + + // Selecting a tab in the tab bar leaves Vaults mode, like clicking + // a terminal tab in the macOS app. + self.showTerminalMode(); } fn tabViewPageAttached( @@ -2091,6 +2096,37 @@ pub const Window = extern struct { self.toggleCommandPalette(); } + /// Toggle the Sarv Vaults mode (sidebar + hosts grid filling the window, + /// like the macOS main window). The header toggle button is the single + /// source of truth; flipping it drives vaultsToggled. + fn actionShowSarvVaults( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const toggle = self.private().vaults_toggle; + toggle.setActive(@intFromBool(toggle.getActive() == 0)); + } + + /// The header "Vaults" toggle flipped: switch the content stack between + /// the terminal and the Vaults view. + fn vaultsToggled(toggle: *gtk.ToggleButton, self: *Window) callconv(.c) void { + const priv = self.private(); + if (toggle.getActive() != 0) { + priv.vaults_view.setWindow(self); + priv.vaults_view.refresh(); + priv.content_stack.setVisibleChildName("vaults"); + } else { + priv.content_stack.setVisibleChildName("terminal"); + } + } + + /// Switch the window back to the terminal view (used by the Vaults view + /// after opening an SSH tab). + pub fn showTerminalMode(self: *Self) void { + self.private().vaults_toggle.setActive(0); + } + /// Present the Sarv hosts dialog. The dialog owns its lifecycle: it /// self-refs while shown and unrefs when closed. fn actionShowSarvHosts( @@ -2199,6 +2235,7 @@ pub const Window = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); + gobject.ext.ensureType(SarvVaultsView); gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gobject.ext.ensureType(Tab); @@ -2231,10 +2268,13 @@ pub const Window = extern struct { class.bindTemplateChildPrivate("tab_view", .{}); class.bindTemplateChildPrivate("toolbar", .{}); class.bindTemplateChildPrivate("toast_overlay", .{}); + class.bindTemplateChildPrivate("content_stack", .{}); + class.bindTemplateChildPrivate("vaults_view", .{}); + class.bindTemplateChildPrivate("vaults_toggle", .{}); // Template Callbacks class.bindTemplateCallback("realize", &windowRealize); - class.bindTemplateCallback("new_tab", &btnNewTab); + class.bindTemplateCallback("vaults_toggled", &vaultsToggled); class.bindTemplateCallback("overview_create_tab", &tabOverviewCreateTab); class.bindTemplateCallback("overview_notify_open", &tabOverviewOpen); class.bindTemplateCallback("close_request", &windowCloseRequest); diff --git a/src/apprt/gtk/ui/1.5/sarv-files-dialog.blp b/src/apprt/gtk/ui/1.5/sarv-files-dialog.blp index 02d0d55c..92308696 100644 --- a/src/apprt/gtk/ui/1.5/sarv-files-dialog.blp +++ b/src/apprt/gtk/ui/1.5/sarv-files-dialog.blp @@ -78,6 +78,7 @@ Adw.Dialog dialog { styles [ "rich-list", + "sarv-flat-list", ] factory: Gtk.BuilderListItemFactory { @@ -203,6 +204,7 @@ Adw.Dialog dialog { styles [ "rich-list", + "sarv-flat-list", ] factory: Gtk.BuilderListItemFactory { diff --git a/src/apprt/gtk/ui/1.5/sarv-vaults-view.blp b/src/apprt/gtk/ui/1.5/sarv-vaults-view.blp new file mode 100644 index 00000000..e665dc76 --- /dev/null +++ b/src/apprt/gtk/ui/1.5/sarv-vaults-view.blp @@ -0,0 +1,239 @@ +using Gtk 4.0; +using Adw 1; + +Gtk.Box root { + orientation: horizontal; + + Gtk.ListBox sidebar { + selection-mode: browse; + vexpand: true; + width-request: 210; + row-selected => $sidebar_selected(); + + styles [ + "navigation-sidebar", + "sarv-vaults-sidebar", + ] + + Gtk.ListBoxRow { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + + Gtk.Image { + icon-name: "computer-symbolic"; + } + + Gtk.Label { + label: _("Hosts"); + halign: start; + } + }; + } + + Gtk.ListBoxRow { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + + Gtk.Image { + icon-name: "dialog-password-symbolic"; + } + + Gtk.Label { + label: _("Keychain"); + halign: start; + } + }; + } + + Gtk.ListBoxRow { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + + Gtk.Image { + icon-name: "network-transmit-receive-symbolic"; + } + + Gtk.Label { + label: _("Port Forwarding"); + halign: start; + } + }; + } + + Gtk.ListBoxRow { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + + Gtk.Image { + icon-name: "format-text-code-symbolic"; + } + + Gtk.Label { + label: _("Snippets"); + halign: start; + } + }; + } + + Gtk.ListBoxRow { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + + Gtk.Image { + icon-name: "security-high-symbolic"; + } + + Gtk.Label { + label: _("Known Hosts"); + halign: start; + } + }; + } + } + + Gtk.Separator { + orientation: vertical; + } + + Gtk.Box { + orientation: vertical; + hexpand: true; + spacing: 12; + margin-top: 12; + margin-bottom: 12; + margin-start: 16; + margin-end: 16; + + Gtk.Box { + orientation: horizontal; + spacing: 8; + + Gtk.SearchEntry search { + hexpand: true; + placeholder-text: _("Find a host or ssh user@hostname…"); + search-changed => $search_changed(); + activate => $quick_connect(); + } + + Gtk.Button connect_btn { + label: _("Connect"); + clicked => $quick_connect(); + + styles [ + "suggested-action", + ] + } + } + + Gtk.Box { + orientation: horizontal; + spacing: 8; + + Gtk.Button { + clicked => $add_clicked(); + + Adw.ButtonContent { + icon-name: "list-add-symbolic"; + label: _("New host"); + } + } + + Gtk.Button { + clicked => $terminal_clicked(); + + Adw.ButtonContent { + icon-name: "utilities-terminal-symbolic"; + label: _("Terminal"); + } + } + + Gtk.Box { + hexpand: true; + } + + Gtk.Button { + clicked => $files_clicked(); + + Adw.ButtonContent { + icon-name: "folder-remote-symbolic"; + label: _("SFTP"); + } + } + + Gtk.Button { + clicked => $sync_clicked(); + + Adw.ButtonContent { + icon-name: "emblem-synchronizing-symbolic"; + label: _("Sync"); + } + } + } + + Gtk.ScrolledWindow { + vexpand: true; + hscrollbar-policy: never; + + Gtk.Box { + orientation: vertical; + spacing: 8; + + Gtk.Label groups_header { + label: _("Groups"); + halign: start; + + styles [ + "heading", + ] + } + + Gtk.FlowBox groups_flow { + selection-mode: single; + homogeneous: true; + max-children-per-line: 4; + min-children-per-line: 2; + column-spacing: 12; + row-spacing: 12; + valign: start; + child-activated => $group_activated(); + } + + Gtk.Label hosts_header { + label: _("Hosts"); + halign: start; + margin-top: 8; + + styles [ + "heading", + ] + } + + Gtk.FlowBox hosts_flow { + selection-mode: single; + homogeneous: true; + max-children-per-line: 4; + min-children-per-line: 2; + column-spacing: 12; + row-spacing: 12; + valign: start; + child-activated => $host_activated(); + } + + Gtk.Label empty_label { + label: _("No saved hosts yet — click “New host” to add one."); + visible: false; + margin-top: 24; + + styles [ + "dim-label", + ] + } + } + } + } +} diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index d7e1f063..2d6c37ec 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -50,17 +50,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; }; - [start] - Adw.SplitButton { - clicked => $new_tab(); - icon-name: "tab-new-symbolic"; - tooltip-text: _("New Tab"); - dropdown-tooltip: _("New Split"); - menu-model: split_menu; - can-focus: false; - focus-on-click: false; - } - [end] Gtk.Box { Gtk.ToggleButton { @@ -91,24 +80,47 @@ template $GhosttyWindow: Adw.ApplicationWindow { [start] Gtk.Box { orientation: horizontal; - visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; Gtk.WindowControls { side: start; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; } - Adw.SplitButton { + // Fixed Vaults + SFTP controls, like the macOS top-left segmented + // control. Terminal tabs open to the right of these. + Gtk.Box { styles [ - "flat", + "linked", ] - clicked => $new_tab(); - icon-name: "tab-new-symbolic"; - tooltip-text: _("New Tab"); - dropdown-tooltip: _("New Split"); - menu-model: split_menu; - can-focus: false; - focus-on-click: false; + margin-start: 2; + margin-end: 8; + margin-top: 4; + margin-bottom: 4; + + Gtk.ToggleButton vaults_toggle { + toggled => $vaults_toggled(); + tooltip-text: _("Vaults"); + can-focus: false; + focus-on-click: false; + + Adw.ButtonContent { + icon-name: "network-server-symbolic"; + label: _("Vaults"); + } + } + + Gtk.Button { + action-name: "win.show-sarv-files"; + tooltip-text: _("SFTP file browser"); + can-focus: false; + focus-on-click: false; + + Adw.ButtonContent { + icon-name: "folder-remote-symbolic"; + label: _("SFTP"); + } + } } } @@ -155,16 +167,31 @@ template $GhosttyWindow: Adw.ApplicationWindow { } Adw.ToastOverlay toast_overlay { - Adw.TabView tab_view { - notify::n-pages => $notify_n_pages(); - notify::selected-page => $notify_selected_page(); - close-page => $close_page(); - page-attached => $page_attached(); - page-detached => $page_detached(); - create-window => $tab_create_window(); - setup-menu => $setup_tab_menu(); - menu-model: tab_context_menu; - shortcuts: none; + Gtk.Stack content_stack { + transition-type: crossfade; + transition-duration: 150; + + Gtk.StackPage { + name: "terminal"; + + child: Adw.TabView tab_view { + notify::n-pages => $notify_n_pages(); + notify::selected-page => $notify_selected_page(); + close-page => $close_page(); + page-attached => $page_attached(); + page-detached => $page_detached(); + create-window => $tab_create_window(); + setup-menu => $setup_tab_menu(); + menu-model: tab_context_menu; + shortcuts: none; + }; + } + + Gtk.StackPage { + name: "vaults"; + + child: $GhosttySarvVaultsView vaults_view {}; + } } } } @@ -208,6 +235,11 @@ menu main_menu { } section { + item { + label: _("Vaults…"); + action: "win.show-sarv-vaults"; + } + item { label: _("Hosts…"); action: "win.show-sarv-hosts"; diff --git a/src/config/Config.zig b/src/config/Config.zig index f68713ca..9f6be5e3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3648,14 +3648,17 @@ else /// by the titles any longer (as they are tab titles now). Other areas of the /// `tabs` title bar can be used to drag the window around. /// -/// The default style is `native`. -@"gtk-titlebar-style": GtkTitlebarStyle = .native, +/// The default style is `tabs`, matching the macOS Sarv Terminal layout +/// where the tab bar is the titlebar: fixed Vaults/SFTP controls on the +/// left and terminal tabs opening to their right in the same row. +@"gtk-titlebar-style": GtkTitlebarStyle = .tabs, -/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs +/// If `true`, then the Ghostty GTK tabs will be "wide." Wide tabs /// are the new typical Gnome style where tabs fill their available space. -/// If you set this to `false` then tabs will only take up space they need, -/// which is the old style. -@"gtk-wide-tabs": bool = true, +/// If you set this to `false` (default) then tabs will only take up space +/// they need, which is the old style and matches the macOS Sarv Terminal +/// where compact tabs sit beside the fixed Vaults/SFTP controls. +@"gtk-wide-tabs": bool = false, /// Custom CSS files to be loaded. /// diff --git a/src/sarv/vault.zig b/src/sarv/vault.zig index adf6b78c..68200e84 100644 --- a/src/sarv/vault.zig +++ b/src/sarv/vault.zig @@ -151,6 +151,27 @@ fn findGroup(groups: []const model.HostGroup, id: []const u8) ?*const model.Host return null; } +/// Whether `group_id` is `root` itself or nested anywhere under it, walking +/// parentID links. Used so selecting a group also covers the hosts of its +/// subgroups (e.g. "Production" includes "Production > Web"). +pub fn groupInSubtree( + groups: []const model.HostGroup, + group_id: ?[]const u8, + root: []const u8, +) bool { + var current: ?[]const u8 = group_id; + var guard: usize = 0; + while (current) |cid| { + // Cycle/corruption guard: group trees are shallow in practice. + guard += 1; + if (guard > 64) return false; + if (std.mem.eql(u8, cid, root)) return true; + const g = findGroup(groups, cid) orelse return false; + current = g.parentID; + } + return false; +} + // Sandbox the config dir to a tmp path for the duration of a test. const TmpConfig = struct { dir: std.testing.TmpDir, @@ -235,6 +256,21 @@ test "sarv: groupPath builds a breadcrumb from parent links" { try std.testing.expectEqualStrings("Production > Web", p); } +test "sarv: groupInSubtree matches the group itself and nested children" { + const groups = [_]model.HostGroup{ + .{ .id = "prod", .name = "Production" }, + .{ .id = "web", .name = "Web", .parentID = "prod" }, + .{ .id = "db", .name = "Databases", .parentID = "prod" }, + .{ .id = "staging", .name = "Staging" }, + }; + try std.testing.expect(groupInSubtree(&groups, "prod", "prod")); + try std.testing.expect(groupInSubtree(&groups, "web", "prod")); + try std.testing.expect(groupInSubtree(&groups, "db", "prod")); + try std.testing.expect(!groupInSubtree(&groups, "staging", "prod")); + try std.testing.expect(!groupInSubtree(&groups, null, "prod")); + try std.testing.expect(!groupInSubtree(&groups, "missing", "prod")); +} + test "sarv: groupPath is empty for nil or unknown id" { const alloc = std.testing.allocator; const groups = [_]model.HostGroup{.{ .id = "root", .name = "Production" }};