From f9c687f35e8aaaa1dbe1e036c272ff024b66cd8c Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Thu, 12 Feb 2026 13:54:12 +0100 Subject: [PATCH 1/4] feat(worktree): create worktrees outside repo tree with post-create init Issue: Agents running in worktrees under .architect/ walk up the directory tree and find the root repo's CLAUDE.md, causing duplicate instructions. Additionally, new worktrees lack project-local state (.env, .envrc.local) and require manual setup commands. Solution: Worktrees are now created at ~/.architect-worktrees// by default, fully outside the repository tree. A new [worktree] config section lets users override the base directory and specify a post-create init command. When no explicit init_command is set, the shell snippet auto-runs script/setup or .architect-init.sh if either is present and executable. Existing worktrees under .architect/ remain discoverable since the worktree picker reads from git metadata, which is location-agnostic. --- README.md | 2 +- docs/ARCHITECTURE.md | 4 +-- docs/configuration.md | 23 ++++++++++++++++ src/app/runtime.zig | 31 ++++++++++++++------- src/app/worktree.zig | 63 +++++++++++++++++++++++++++++++++++-------- src/config.zig | 40 +++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9d78bf5..caed0ba 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Status highlights** — agents glow when awaiting approval or done, so you never miss a prompt - **Dynamic grid** — starts with a single terminal in full view; press ⌘N to add a terminal after the current one, and closing terminals compacts the grid forward - **Grid view** — keep all agents visible simultaneously, expand any one to full screen -- **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches +- **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches; new worktrees are created outside the repo tree (configurable via `[worktree]` in `config.toml`) with automatic post-create initialization - **Recent folders** (⌘O) — quickly `cd` into recently visited directories with arrow key selection - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments, then send them all to a running agent (or start one) with the "Send to agent" button - **Story viewer** — run `architect story ` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 001639d..68777c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -264,7 +264,7 @@ Renderer draws attention border / story overlay | Terminal cell buffer | In-memory (ghostty-vt) | Current screen + scrollback (up to 10KB default) | | Glyph cache | GPU textures + in-memory LRU | Up to 4096 shaped glyph textures | | Render cache | GPU textures per session | Cached terminal renders, epoch-invalidated | -| config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags) | +| config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags, worktree location) | | persistence.toml | `~/.config/architect/persistence.toml` | Runtime state (window pos, font size, terminal cwds) | | diff_comments.json | `/.architect/diff_comments.json` | Per-repo inline diff review comments (unsent) | @@ -283,7 +283,7 @@ Renderer draws attention border / story overlay |--------|---------------|----------------------------------|--------------| | `main.zig` | Thin entrypoint | `main()` | `app/runtime` | | `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence | `run()`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, all `app/*` modules | -| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` | +| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands (with configurable external directory and post-create init) | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()`, `resolveWorktreeDir()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` | | `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` | | `input/mapper.zig` | SDL keycodes to VT escape sequences, shortcut detection | `encodeKey()`, modifier helpers | `c` | | `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) | diff --git a/docs/configuration.md b/docs/configuration.md index 44cf14e..852757c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -138,6 +138,25 @@ When enabled, press `Cmd+Shift+M` to toggle the metrics overlay in the bottom-ri Metrics collection has zero overhead when disabled (no allocations, null pointer checks compile away). +### Worktree Configuration + +```toml +[worktree] +directory = "~/.architect-worktrees" # Base directory for new worktrees +init_command = "script/setup" # Command to run after creating a worktree +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `directory` | `~/.architect-worktrees` | Base directory where new worktrees are created. Each repo gets a subdirectory named after the repo, and each worktree is a subdirectory within that. Supports `~` expansion. | +| `init_command` | *(auto-detect)* | Shell command to run in the new worktree after creation. When not set, Architect automatically runs `script/setup` or `.architect-init.sh` if either exists and is executable. | + +New worktrees are created at `//`. For example, with the default directory and a repo named `myproject`, creating a worktree called `feature-x` produces `~/.architect-worktrees/myproject/feature-x`. + +Creating worktrees outside the repository tree prevents agents (Claude Code, Codex, etc.) from discovering the parent repository's configuration files when traversing up the directory tree. + +Existing worktrees (including legacy ones under `.architect/` inside the repo) remain visible in the worktree picker and can be switched to or removed normally. + ### Complete Example ```toml @@ -183,6 +202,10 @@ vsync = true [metrics] enabled = false + +[worktree] +# directory = "~/.architect-worktrees" +# init_command = "script/setup" ``` ## persistence.toml diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 465135b..175e478 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -1744,7 +1744,25 @@ pub fn run() !void { continue; } - const command = worktree.buildCreateWorktreeCommand(allocator, create_action.base_path, create_action.name) catch |err| { + const target_path = worktree.resolveWorktreeDir( + allocator, + create_action.base_path, + create_action.name, + config.worktree.directory, + ) catch |err| { + std.debug.print("Failed to resolve worktree directory: {}\n", .{err}); + ui.showToast("Could not create worktree", now); + continue; + }; + defer allocator.free(target_path); + + const command = worktree.buildCreateWorktreeCommand( + allocator, + create_action.base_path, + target_path, + create_action.name, + config.worktree.init_command, + ) catch |err| { std.debug.print("Failed to build worktree command: {}\n", .{err}); ui.showToast("Could not create worktree", now); continue; @@ -1757,14 +1775,9 @@ pub fn run() !void { continue; }; - // Update cwd to the new worktree path for UI purposes. - const new_path = std.fs.path.join(allocator, &.{ create_action.base_path, ".architect", create_action.name }) catch null; - if (new_path) |abs| { - session.recordCwd(abs) catch |err| { - log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err }); - }; - allocator.free(abs); - } + session.recordCwd(target_path) catch |err| { + log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err }); + }; session_interaction_component.setStatus(create_action.session, .running); session_interaction_component.setAttention(create_action.session, false, now); diff --git a/src/app/worktree.zig b/src/app/worktree.zig index 02926b1..41d15d4 100644 --- a/src/app/worktree.zig +++ b/src/app/worktree.zig @@ -37,24 +37,65 @@ pub fn changeSessionDirectory(session: *SessionState, allocator: std.mem.Allocat try session.recordCwd(path); } -pub fn buildCreateWorktreeCommand(allocator: std.mem.Allocator, base_path: []const u8, name: []const u8) ![]u8 { +/// Resolve the absolute worktree target directory for a new worktree. +/// If `config_dir` is set, expands `~` and appends `/`. +/// Otherwise defaults to `~/.architect-worktrees//`. +pub fn resolveWorktreeDir(allocator: std.mem.Allocator, repo_root: []const u8, name: []const u8, config_dir: ?[]const u8) ![]u8 { + const home = std.posix.getenv("HOME") orelse return error.HomeNotFound; + const repo_name = std.fs.path.basename(repo_root); + + const base = if (config_dir) |dir| + if (dir.len > 0 and dir[0] == '~') + try std.fs.path.join(allocator, &.{ home, dir[1..], repo_name }) + else + try std.fs.path.join(allocator, &.{ dir, repo_name }) + else + try std.fs.path.join(allocator, &.{ home, ".architect-worktrees", repo_name }); + defer allocator.free(base); + + return std.fs.path.join(allocator, &.{ base, name }); +} + +pub fn buildCreateWorktreeCommand( + allocator: std.mem.Allocator, + repo_root: []const u8, + target_path: []const u8, + name: []const u8, + init_command: ?[]const u8, +) ![]u8 { var cmd: std.ArrayList(u8) = .empty; errdefer cmd.deinit(allocator); + // cd to repo root so git worktree add runs in the right context try cmd.appendSlice(allocator, clear_line_prefix ++ "cd -- "); - try appendQuotedPath(&cmd, allocator, base_path); - try cmd.appendSlice(allocator, " && mkdir -p .architect && git worktree add "); - - const target_rel = try std.fmt.allocPrint(allocator, ".architect/{s}", .{name}); - defer allocator.free(target_rel); - - try appendQuotedPath(&cmd, allocator, target_rel); + try appendQuotedPath(&cmd, allocator, repo_root); + + // create parent directory and add worktree + const parent = std.fs.path.dirname(target_path) orelse target_path; + try cmd.appendSlice(allocator, " && mkdir -p "); + try appendQuotedPath(&cmd, allocator, parent); + try cmd.appendSlice(allocator, " && git worktree add "); + try appendQuotedPath(&cmd, allocator, target_path); try cmd.appendSlice(allocator, " -b "); try appendQuotedPath(&cmd, allocator, name); - try cmd.appendSlice(allocator, " && cd -- "); - try appendQuotedPath(&cmd, allocator, target_rel); - try cmd.appendSlice(allocator, "\n"); + // cd into the new worktree + try cmd.appendSlice(allocator, " && cd -- "); + try appendQuotedPath(&cmd, allocator, target_path); + + // run init command (explicit or auto-detected) + if (init_command) |ic| { + try cmd.appendSlice(allocator, " && "); + try cmd.appendSlice(allocator, ic); + } else { + try cmd.appendSlice( + allocator, + " && if [ -x script/setup ]; then script/setup;" ++ + " elif [ -x .architect-init.sh ]; then ./.architect-init.sh; fi", + ); + } + + try cmd.append(allocator, '\n'); return cmd.toOwnedSlice(allocator); } diff --git a/src/config.zig b/src/config.zig index 65a42ef..6f6257f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -230,6 +230,38 @@ pub const MetricsConfig = struct { enabled: bool = false, }; +pub const WorktreeConfig = struct { + directory: ?[]const u8 = null, + init_command: ?[]const u8 = null, + directory_owned: bool = false, + init_command_owned: bool = false, + + pub fn deinit(self: *WorktreeConfig, allocator: std.mem.Allocator) void { + if (self.directory_owned) { + if (self.directory) |v| allocator.free(v); + } + if (self.init_command_owned) { + if (self.init_command) |v| allocator.free(v); + } + self.directory = null; + self.init_command = null; + self.directory_owned = false; + self.init_command_owned = false; + } + + pub fn duplicate(self: WorktreeConfig, allocator: std.mem.Allocator) !WorktreeConfig { + const dir_dup = if (self.directory) |d| try allocator.dupe(u8, d) else null; + errdefer if (dir_dup) |d| allocator.free(d); + const cmd_dup = if (self.init_command) |c| try allocator.dupe(u8, c) else null; + return WorktreeConfig{ + .directory = dir_dup, + .init_command = cmd_dup, + .directory_owned = dir_dup != null, + .init_command_owned = cmd_dup != null, + }; + } +}; + pub const Persistence = struct { const terminal_key_prefix = "terminal_"; const max_recent_folders: usize = 10; @@ -601,6 +633,7 @@ pub const Config = struct { ui: UiConfig = .{}, rendering: Rendering = .{}, metrics: MetricsConfig = .{}, + worktree: WorktreeConfig = .{}, pub fn load(allocator: std.mem.Allocator) LoadError!Config { const config_path = try getConfigPath(allocator); @@ -679,6 +712,11 @@ pub const Config = struct { \\# [metrics] \\# enabled = false \\ + \\# Worktree options + \\# [worktree] + \\# directory = "~/.architect-worktrees" # Base directory for new worktrees (default: ~/.architect-worktrees) + \\# init_command = "script/setup" # Command to run after creating a worktree + \\ ; const file = try fs.createFileAbsolute(config_path, .{ .truncate = true }); @@ -711,6 +749,7 @@ pub const Config = struct { config.font = try config.font.duplicate(allocator); config.theme = try config.theme.duplicate(allocator); + config.worktree = try config.worktree.duplicate(allocator); return config; } @@ -718,6 +757,7 @@ pub const Config = struct { pub fn deinit(self: *Config, allocator: std.mem.Allocator) void { self.font.deinit(allocator); self.theme.deinit(allocator); + self.worktree.deinit(allocator); } pub fn getFontSize(self: Config) i32 { From 40e77a71a5b4641464af1d36c4e32ecd413462b3 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Thu, 12 Feb 2026 14:08:30 +0100 Subject: [PATCH 2/4] fix(worktree): correct tilde expansion, relative paths, and repo collisions Issue: Three bugs in resolveWorktreeDir: tilde expansion passed a leading "/" into path.join which discarded $HOME; relative config paths resolved inside the repo root; and basename-only repo IDs caused collisions between repos with the same directory name. Solution: Split resolution into dedicated helpers. resolveConfigDir handles ~, ~/..., relative, and absolute paths correctly. repoSubpath uses the full path relative to $HOME (or minus leading / for repos outside $HOME) instead of just the basename, making the worktree directory structure collision-proof. --- docs/configuration.md | 4 ++-- src/app/worktree.zig | 54 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 852757c..fc1ecf7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,10 +148,10 @@ init_command = "script/setup" # Command to run after creating a worktree | Setting | Default | Description | |---------|---------|-------------| -| `directory` | `~/.architect-worktrees` | Base directory where new worktrees are created. Each repo gets a subdirectory named after the repo, and each worktree is a subdirectory within that. Supports `~` expansion. | +| `directory` | `~/.architect-worktrees` | Base directory where new worktrees are created. Each repo gets a subdirectory mirroring its path relative to `$HOME`, and each worktree is a subdirectory within that. Supports `~` expansion. Relative paths are resolved against `$HOME`. | | `init_command` | *(auto-detect)* | Shell command to run in the new worktree after creation. When not set, Architect automatically runs `script/setup` or `.architect-init.sh` if either exists and is executable. | -New worktrees are created at `//`. For example, with the default directory and a repo named `myproject`, creating a worktree called `feature-x` produces `~/.architect-worktrees/myproject/feature-x`. +New worktrees are created at `//`, where `` is the repo's path relative to `$HOME`. For example, if the repo is at `~/dev/myproject`, creating a worktree called `feature-x` produces `~/.architect-worktrees/dev/myproject/feature-x`. Creating worktrees outside the repository tree prevents agents (Claude Code, Codex, etc.) from discovering the parent repository's configuration files when traversing up the directory tree. diff --git a/src/app/worktree.zig b/src/app/worktree.zig index 41d15d4..b6808ae 100644 --- a/src/app/worktree.zig +++ b/src/app/worktree.zig @@ -38,24 +38,56 @@ pub fn changeSessionDirectory(session: *SessionState, allocator: std.mem.Allocat } /// Resolve the absolute worktree target directory for a new worktree. -/// If `config_dir` is set, expands `~` and appends `/`. -/// Otherwise defaults to `~/.architect-worktrees//`. +/// If `config_dir` is set, expands `~` and appends `/`. +/// Otherwise defaults to `~/.architect-worktrees//`. +/// The repo subpath is relative to $HOME when possible, or the full path minus +/// leading `/` otherwise, to avoid collisions between repos with the same basename. pub fn resolveWorktreeDir(allocator: std.mem.Allocator, repo_root: []const u8, name: []const u8, config_dir: ?[]const u8) ![]u8 { const home = std.posix.getenv("HOME") orelse return error.HomeNotFound; - const repo_name = std.fs.path.basename(repo_root); - - const base = if (config_dir) |dir| - if (dir.len > 0 and dir[0] == '~') - try std.fs.path.join(allocator, &.{ home, dir[1..], repo_name }) - else - try std.fs.path.join(allocator, &.{ dir, repo_name }) - else - try std.fs.path.join(allocator, &.{ home, ".architect-worktrees", repo_name }); + const repo_subpath = repoSubpath(home, repo_root); + + const resolved_dir = try resolveConfigDir(allocator, home, config_dir); + defer allocator.free(resolved_dir); + + const base = try std.fs.path.join(allocator, &.{ resolved_dir, repo_subpath }); defer allocator.free(base); return std.fs.path.join(allocator, &.{ base, name }); } +/// Resolve the config directory to an absolute path. +/// Expands `~`/`~/...` relative to home, resolves relative paths against home, +/// and returns absolute paths as-is. Returns default when config_dir is null. +fn resolveConfigDir(allocator: std.mem.Allocator, home: []const u8, config_dir: ?[]const u8) ![]u8 { + const dir = config_dir orelse + return std.fs.path.join(allocator, &.{ home, ".architect-worktrees" }); + + if (dir.len > 0 and dir[0] == '~') { + if (dir.len == 1) return allocator.dupe(u8, home); + if (dir[1] == '/') return std.fs.path.join(allocator, &.{ home, dir[2..] }); + return std.fs.path.join(allocator, &.{ home, dir[1..] }); + } + + if (!std.fs.path.isAbsolute(dir)) { + return std.fs.path.join(allocator, &.{ home, dir }); + } + + return allocator.dupe(u8, dir); +} + +/// Derive a collision-safe repo identifier from repo_root. +/// Uses the path relative to $HOME when possible, or the full path minus +/// leading `/` when the repo is outside $HOME. +fn repoSubpath(home: []const u8, repo_root: []const u8) []const u8 { + if (std.mem.startsWith(u8, repo_root, home)) { + const after_home = repo_root[home.len..]; + if (after_home.len > 0 and after_home[0] == '/') return after_home[1..]; + if (after_home.len == 0) return std.fs.path.basename(repo_root); + } + if (repo_root.len > 0 and repo_root[0] == '/') return repo_root[1..]; + return repo_root; +} + pub fn buildCreateWorktreeCommand( allocator: std.mem.Allocator, repo_root: []const u8, From 3a4f82374b3c258dc5a8a203bf79312d378820db Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Thu, 12 Feb 2026 15:33:48 +0100 Subject: [PATCH 3/4] test --- script/setup | 3 +++ 1 file changed, 3 insertions(+) create mode 100755 script/setup diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..fb0cb93 --- /dev/null +++ b/script/setup @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Hello from custom script" From 920b8e373e3486daee69d80b66f82e22b8f4b489 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Thu, 12 Feb 2026 15:45:03 +0100 Subject: [PATCH 4/4] feat(worktree): show tilde paths for external worktrees and wrap long paths in modal Issue: External worktrees displayed as ugly ../../.architect-worktrees/... relative paths in the Cmd+T overlay and removal modal. Long paths also overflowed the 520px-wide removal confirmation modal. Solution: makeDisplayPath now uses ~/... for worktrees outside the repo tree instead of relative paths with ../ prefixes. The removal modal wraps long paths at / boundaries across multiple centered lines when the path exceeds the modal's inner width. --- src/ui/components/worktree_overlay.zig | 90 ++++++++++++++++++++------ 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/src/ui/components/worktree_overlay.zig b/src/ui/components/worktree_overlay.zig index bb93cc0..cd3e3ba 100644 --- a/src/ui/components/worktree_overlay.zig +++ b/src/ui/components/worktree_overlay.zig @@ -636,11 +636,18 @@ pub const WorktreeOverlayComponent = struct { } fn makeDisplayPath(self: *WorktreeOverlayComponent, base: []const u8, abs: []const u8) ![]const u8 { - const rel = std.fs.path.relative(self.allocator, base, abs) catch { - return self.allocator.dupe(u8, abs); - }; - if (rel.len == 0) return self.allocator.dupe(u8, repository_root_label); - return rel; + if (std.mem.startsWith(u8, abs, base)) { + const rel = std.fs.path.relative(self.allocator, base, abs) catch { + return self.allocator.dupe(u8, abs); + }; + if (rel.len == 0) return self.allocator.dupe(u8, repository_root_label); + return rel; + } + const home = std.posix.getenv("HOME") orelse return self.allocator.dupe(u8, abs); + if (std.mem.startsWith(u8, abs, home) and abs.len > home.len and abs[home.len] == '/') { + return std.fmt.allocPrint(self.allocator, "~{s}", .{abs[home.len..]}); + } + return self.allocator.dupe(u8, abs); } fn ensureCache(self: *WorktreeOverlayComponent, renderer: *c.SDL_Renderer, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) ?*Cache { @@ -888,6 +895,62 @@ pub const WorktreeOverlayComponent = struct { }; } + fn renderWrappedPath( + renderer: *c.SDL_Renderer, + font: *c.TTF_Font, + text: []const u8, + color: c.SDL_Color, + modal_x: f32, + start_y: f32, + modal_w: f32, + max_w: c_int, + row_height: c_int, + ) void { + var full_w: c_int = 0; + var full_h: c_int = 0; + _ = c.TTF_GetStringSize(font, text.ptr, text.len, &full_w, &full_h); + + if (full_w <= max_w) { + const tex = makeTextTexture(renderer, font, text, color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + const x = modal_x + (modal_w - @as(f32, @floatFromInt(tex.w))) / 2.0; + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ .x = x, .y = start_y, .w = @floatFromInt(tex.w), .h = @floatFromInt(tex.h) }); + return; + } + + var y = start_y; + var line_start: usize = 0; + var last_slash: usize = 0; + + for (text, 0..) |ch, i| { + if (ch == '/' and i > line_start) last_slash = i; + + const segment = text[line_start .. i + 1]; + var seg_w: c_int = 0; + var seg_h: c_int = 0; + _ = c.TTF_GetStringSize(font, segment.ptr, segment.len, &seg_w, &seg_h); + + if (seg_w > max_w and last_slash > line_start) { + const line = text[line_start .. last_slash + 1]; + const tex = makeTextTexture(renderer, font, line, color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + const x = modal_x + (modal_w - @as(f32, @floatFromInt(tex.w))) / 2.0; + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ .x = x, .y = y, .w = @floatFromInt(tex.w), .h = @floatFromInt(tex.h) }); + y += @floatFromInt(row_height); + line_start = last_slash + 1; + last_slash = line_start; + } + } + + if (line_start < text.len) { + const line = text[line_start..]; + const tex = makeTextTexture(renderer, font, line, color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + const x = modal_x + (modal_w - @as(f32, @floatFromInt(tex.w))) / 2.0; + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ .x = x, .y = y, .w = @floatFromInt(tex.w), .h = @floatFromInt(tex.h) }); + } + } + fn destroyEntryTextures(entries: []EntryTex) void { for (entries) |entry| { c.SDL_DestroyTexture(entry.hotkey.tex); @@ -1316,20 +1379,9 @@ pub const WorktreeOverlayComponent = struct { const worktree = self.worktrees.items[wt_idx]; const message_y = layout.modal.y + @as(f32, @floatFromInt(dpi.scale(50, host.ui_scale))); const message_color = c.SDL_Color{ .r = theme.foreground.r, .g = theme.foreground.g, .b = theme.foreground.b, .a = 200 }; - const message_tex = makeTextTexture(renderer, entry_fonts.regular, worktree.display, message_color) catch |err| blk: { - log.warn("failed to create worktree message texture: {}", .{err}); - break :blk null; - }; - if (message_tex) |tex| { - defer c.SDL_DestroyTexture(tex.tex); - const message_x = layout.modal.x + (layout.modal.w - @as(f32, @floatFromInt(tex.w))) / 2.0; - _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ - .x = message_x, - .y = message_y, - .w = @floatFromInt(tex.w), - .h = @floatFromInt(tex.h), - }); - } + const max_w: c_int = @as(c_int, @intFromFloat(layout.modal.w)) - 2 * dpi.scale(modal_padding, host.ui_scale); + const scaled_lh: c_int = dpi.scale(line_height, host.ui_scale); + renderWrappedPath(renderer, entry_fonts.regular, worktree.display, message_color, layout.modal.x, message_y, layout.modal.w, max_w, scaled_lh); } }