diff --git a/.github/assets/CommandNotch-Demo.mp4 b/.github/assets/CommandNotch-Demo.mp4 new file mode 100644 index 0000000..d05383f Binary files /dev/null and b/.github/assets/CommandNotch-Demo.mp4 differ diff --git a/.github/assets/CommandNotch-Open.png b/.github/assets/CommandNotch-Open.png new file mode 100644 index 0000000..b42e84d Binary files /dev/null and b/.github/assets/CommandNotch-Open.png differ diff --git a/.github/assets/CommandNotch-Settings.png b/.github/assets/CommandNotch-Settings.png new file mode 100644 index 0000000..6b6f43b Binary files /dev/null and b/.github/assets/CommandNotch-Settings.png differ diff --git a/.github/assets/bch-qr-placeholder.svg b/.github/assets/bch-qr-placeholder.svg deleted file mode 100644 index 7c0e26b..0000000 --- a/.github/assets/bch-qr-placeholder.svg +++ /dev/null @@ -1,11 +0,0 @@ - - BCH QR placeholder - Placeholder card reminding the maintainer to replace this with a real BCH QR code. - - - - - - REPLACE WITH REAL BCH QR - ADD YOUR bitcoincash: ADDRESS - diff --git a/.github/assets/bch-receiving-address.png b/.github/assets/bch-receiving-address.png new file mode 100644 index 0000000..c4162bf Binary files /dev/null and b/.github/assets/bch-receiving-address.png differ diff --git a/.github/assets/downterm-open.png b/.github/assets/downterm-open.png deleted file mode 100644 index a9d8821..0000000 Binary files a/.github/assets/downterm-open.png and /dev/null differ diff --git a/.github/assets/downterm-settings.png b/.github/assets/downterm-settings.png deleted file mode 100644 index 306141d..0000000 Binary files a/.github/assets/downterm-settings.png and /dev/null differ diff --git a/.github/demo/aurora.py b/.github/demo/aurora.py new file mode 100755 index 0000000..e76e15f --- /dev/null +++ b/.github/demo/aurora.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Looping bathtub-and-duck terminal animation for Downterm demos.""" + +from __future__ import annotations + +import argparse +import atexit +import math +import shutil +import signal +import sys +import time +from typing import Sequence + + +CSI = "\x1b[" +RESET = f"{CSI}0m" +HIDE_CURSOR = f"{CSI}?25l" +SHOW_CURSOR = f"{CSI}?25h" +ALT_SCREEN_ON = f"{CSI}?1049h" +ALT_SCREEN_OFF = f"{CSI}?1049l" +CLEAR = f"{CSI}2J" +HOME = f"{CSI}H" + +TUB = "tub" +TUB_DARK = "tub_dark" +WATER = "water" +WATER_DEEP = "water_deep" +FOAM = "foam" +BUBBLE = "bubble" +DUCK = "duck" +DUCK_LIGHT = "duck_light" +BEAK = "beak" +EYE = "eye" +LABEL = "label" +LABEL_SHADOW = "label_shadow" + +RGB = { + TUB: (171, 177, 191), + TUB_DARK: (112, 118, 133), + WATER: (71, 185, 214), + WATER_DEEP: (36, 121, 161), + FOAM: (196, 242, 250), + BUBBLE: (184, 233, 245), + DUCK: (255, 208, 64), + DUCK_LIGHT: (255, 232, 132), + BEAK: (255, 154, 58), + EYE: (251, 251, 253), + LABEL: (207, 212, 222), + LABEL_SHADOW: (74, 79, 92), +} + +WATER_SHADE = " .:-=+*#" + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render a looping bathtub-and-duck animation." + ) + parser.add_argument( + "--fps", + type=float, + default=18.0, + help="Target frames per second. Default: 18", + ) + parser.add_argument( + "--speed", + type=float, + default=1.0, + help="Animation speed multiplier. Default: 1.0", + ) + parser.add_argument( + "--duration", + type=float, + default=0.0, + help="Optional run duration in seconds. Default: infinite", + ) + parser.add_argument( + "--label", + default="", + help="Optional centered label, e.g. --label Downterm", + ) + parser.add_argument( + "--mono", + action="store_true", + help="Disable color and render as monochrome ASCII.", + ) + return parser.parse_args(argv) + + +def terminal_size() -> tuple[int, int]: + size = shutil.get_terminal_size((90, 28)) + return max(40, size.columns), max(12, size.lines) + + +def clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def scene_size(width: int, height: int) -> tuple[int, int]: + visible_height = max(8, height - 1) + scene_width = min(max(48, width - 2), width, 110) + scene_height = min(max(16, visible_height - 1), visible_height, 24) + return scene_width, scene_height + + +def make_canvas(width: int, height: int) -> list[list[tuple[str, str | None]]]: + return [[(" ", None) for _ in range(width)] for _ in range(height)] + + +def put(canvas: list[list[tuple[str, str | None]]], x: int, y: int, char: str, color: str | None) -> None: + if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]) and char: + canvas[y][x] = (char[0], color) + + +def draw_text( + canvas: list[list[tuple[str, str | None]]], + x: int, + y: int, + text: str, + color: str | None, +) -> None: + for index, char in enumerate(text): + if char != " ": + put(canvas, x + index, y, char, color) + + +def draw_sprite_text( + canvas: list[list[tuple[str, str | None]]], + x: int, + y: int, + text: str, + color: str | None, +) -> None: + for index, char in enumerate(text): + if char == " ": + continue + if char == "~": + put(canvas, x + index, y, " ", None) + continue + put(canvas, x + index, y, char, color) + + +def water_char(intensity: float) -> str: + index = int(clamp(intensity, 0.0, 0.9999) * len(WATER_SHADE)) + return WATER_SHADE[index] + + +def tub_geometry(scene_width: int, scene_height: int) -> dict[str, int]: + top_y = 1 + notch_width = max(28, min(scene_width - 18, int(scene_width * 0.44))) + rim_left = max(8, (scene_width - notch_width) // 2) + rim_right = min(scene_width - 9, rim_left + notch_width) + wall_left = rim_left + 1 + wall_right = rim_right - 1 + body_bottom = min(scene_height - 10, top_y + max(6, scene_height // 3)) + water_top = top_y + 2 + return { + "rim_left": rim_left, + "rim_right": rim_right, + "top_y": top_y, + "wall_left": wall_left, + "wall_right": wall_right, + "body_bottom": body_bottom, + "water_top": water_top, + } + + +def draw_tub(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int]) -> None: + rim_left = geometry["rim_left"] + rim_right = geometry["rim_right"] + top_y = geometry["top_y"] + wall_left = geometry["wall_left"] + wall_right = geometry["wall_right"] + body_bottom = geometry["body_bottom"] + + draw_text(canvas, 2, top_y, "_" * max(0, rim_left - 2), TUB) + put(canvas, rim_left, top_y, "_", TUB) + draw_text(canvas, rim_right + 1, top_y, "_" * max(0, len(canvas[0]) - rim_right - 3), TUB) + put(canvas, rim_right, top_y, "_", TUB) + + for y in range(top_y + 1, body_bottom): + put(canvas, wall_left, y, "|", TUB) + put(canvas, wall_right, y, "|", TUB) + + put(canvas, wall_left, body_bottom, "\\", TUB_DARK) + draw_text(canvas, wall_left + 1, body_bottom, "_" * max(0, wall_right - wall_left - 1), TUB_DARK) + put(canvas, wall_right, body_bottom, "/", TUB_DARK) + + +def draw_water_fill(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: + rim_left = geometry["wall_left"] + 1 + rim_right = geometry["wall_right"] - 1 + water_top = geometry["water_top"] + body_bottom = geometry["body_bottom"] - 1 + + depth = max(1, body_bottom - water_top) + for row in range(depth + 1): + y = water_top + row + for x in range(rim_left, rim_right + 1): + wave = math.sin((x * 0.23) + (t * 2.1) + row * 0.55) + shimmer = math.sin((x * 0.07) - (t * 1.35)) + if row == 0: + char = "~" if wave > -0.22 else "-" + color = FOAM if shimmer > 0.25 else WATER + else: + density = 0.40 + row / max(1, depth) + value = 0.45 + (wave * 0.25) + (shimmer * 0.10) + density * 0.30 + char = water_char(value) + color = WATER if row <= 2 else WATER_DEEP + put(canvas, x, y, char, color) + + +def draw_water_surface(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: + left = geometry["wall_left"] + 1 + right = geometry["wall_right"] - 1 + y = geometry["water_top"] + + for x in range(left, right + 1): + wave = math.sin((x * 0.23) + (t * 2.1)) + shimmer = math.sin((x * 0.11) - (t * 1.15)) + char = "~" if wave > -0.18 else "-" + color = FOAM if shimmer > 0.35 else WATER + put(canvas, x, y, char, color) + + +def draw_bubbles(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: + rim_left = geometry["wall_left"] + water_top = geometry["water_top"] + + seeds = ( + (0.00, rim_left + 11, 0.9), + (0.29, rim_left + 19, 1.1), + (0.57, rim_left + 27, 0.8), + (0.82, rim_left + 35, 1.0), + ) + + for offset, base_x, speed in seeds: + progress = (t * 0.11 * speed + offset) % 1.0 + y = water_top + 4 - int(progress * 9) + x = base_x + int(round(math.sin((t * 0.8 * speed) + offset * 8.0) * 2)) + put(canvas, x, y, "o" if progress < 0.5 else ".", BUBBLE) + + +def draw_duck(canvas: list[list[tuple[str, str | None]]], geometry: dict[str, int], t: float) -> None: + rim_left = geometry["wall_left"] + rim_right = geometry["wall_right"] + water_top = geometry["water_top"] + + span = rim_right - rim_left + bob = math.sin(t * 1.35) * 0.55 + drift = math.sin(t * 0.55) * 2.0 + + blink_phase = int(t * 2.1) % 12 + eye_char = "-" if blink_phase == 7 else "." + x = int(rim_left + span * 0.34 + drift) + y = int(water_top - 1 + bob) + + rows = [ + " __", + f" ___(~{eye_char})>", + " \\~<_.~) ", + " `---' ", + ] + colors = [DUCK_LIGHT, DUCK, DUCK, DUCK] + + for index, row in enumerate(rows): + draw_sprite_text(canvas, x, y + index, row, colors[index]) + + put(canvas, x + 8, y + 1, ">", BEAK) + put(canvas, x + 6, y + 1, eye_char, EYE) + + if 0.74 < (t * 0.18) % 1.0 < 0.90: + splash_x = x + 9 + splash_y = geometry["water_top"] - 1 + for dx, dy, char in ((0, 0, "."), (2, -1, "o"), (4, 0, "."), (5, -2, "o")): + put(canvas, splash_x + dx, splash_y + dy, char, BUBBLE) + + +def logo_rows(text: str) -> list[str]: + if text == "commandNotch": + return [ + " ██████╗ ██████╗ ███╗ ███╗███╗ ███╗ █████╗ ███╗ ██╗██████╗ ", + " ██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔══██╗████╗ ██║██╔══██╗", + " ██║ ██║ ██║██╔████╔██║██╔████╔██║███████║██╔██╗ ██║██║ ██║", + " ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║", + " ╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║██║ ██║██║ ╚████║██████╔╝", + " ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ", + "", + " ███╗ ██╗ ██████╗ ████████╗ ██████╗██╗ ██╗", + " ████╗ ██║██╔═══██╗╚══██╔══╝██╔════╝██║ ██║", + " ██╔██╗ ██║██║ ██║ ██║ ██║ ███████║", + " ██║╚██╗██║██║ ██║ ██║ ██║ ██╔══██║", + " ██║ ╚████║╚██████╔╝ ██║ ╚██████╗██║ ██║", + " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝", + ] + + return [text] + + +def draw_label(canvas: list[list[tuple[str, str | None]]], label: str) -> None: + logo = logo_rows(label or "commandNotch") + start_y = len(canvas) - len(logo) - 1 + + for index, row in enumerate(logo): + start_x = max(0, (len(canvas[0]) - len(row)) // 2) + draw_text(canvas, start_x, start_y + index, row, LABEL) + + +def scene_canvas(scene_width: int, scene_height: int, t: float, label: str) -> list[list[tuple[str, str | None]]]: + canvas = make_canvas(scene_width, scene_height) + geometry = tub_geometry(scene_width, scene_height) + draw_tub(canvas, geometry) + draw_water_fill(canvas, geometry, t) + draw_water_surface(canvas, geometry, t) + draw_duck(canvas, geometry, t) + draw_bubbles(canvas, geometry, t) + draw_label(canvas, label) + return canvas + + +def ansi_rgb(color_name: str | None) -> str: + if color_name is None: + return RESET + red, green, blue = RGB[color_name] + return f"{CSI}38;2;{red};{green};{blue}m" + + +def render_scene_line(cells: list[tuple[str, str | None]], mono: bool) -> str: + if mono: + return "".join(char for char, _ in cells) + + chunks: list[str] = [] + current_color: str | None = None + for char, color in cells: + if color != current_color: + chunks.append(ansi_rgb(color)) + current_color = color + chunks.append(char) + chunks.append(RESET) + return "".join(chunks) + + +def render_frame(width: int, height: int, t: float, label: str, mono: bool) -> str: + visible_height = max(8, height - 1) + scene_width, scene_height = scene_size(width, height) + scene = scene_canvas(scene_width, scene_height, t, label) + top_padding = max(0, (visible_height - scene_height) // 2) + left_padding = max(0, (width - scene_width) // 2) + + pieces: list[str] = [HOME] + blank_line = " " * width + + for row in range(visible_height): + if top_padding <= row < top_padding + scene_height: + scene_row = scene[row - top_padding] + rendered = render_scene_line(scene_row, mono) + right_padding = max(0, width - left_padding - scene_width) + line = (" " * left_padding) + rendered + (" " * right_padding) + else: + line = blank_line + + pieces.append(line) + if row != visible_height - 1: + pieces.append("\n") + + return "".join(pieces) + + +def restore_terminal() -> None: + sys.stdout.write(f"{RESET}{SHOW_CURSOR}{ALT_SCREEN_OFF}") + sys.stdout.flush() + + +def install_cleanup() -> None: + def handle_signal(signum, _frame) -> None: + restore_terminal() + raise SystemExit(128 + signum) + + atexit.register(restore_terminal) + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + +def main(argv: Sequence[str]) -> int: + args = parse_args(argv) + if args.fps <= 0: + raise SystemExit("--fps must be greater than 0") + if args.speed <= 0: + raise SystemExit("--speed must be greater than 0") + + install_cleanup() + + sys.stdout.write(f"{ALT_SCREEN_ON}{HIDE_CURSOR}{CLEAR}") + sys.stdout.flush() + + frame_interval = 1.0 / args.fps + start = time.perf_counter() + frame_index = 0 + + while True: + now = time.perf_counter() + elapsed = now - start + if args.duration > 0 and elapsed >= args.duration: + break + + width, height = terminal_size() + t = elapsed * args.speed + sys.stdout.write(render_frame(width, height, t, args.label, args.mono)) + sys.stdout.flush() + + frame_index += 1 + target = start + (frame_index * frame_interval) + sleep_for = target - time.perf_counter() + if sleep_for > 0: + time.sleep(sleep_for) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index feeeadf..bdfdd8a 100644 Binary files a/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate and b/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/CommandNotch/CommandNotch/Models/TerminalSession.swift b/CommandNotch/CommandNotch/Models/TerminalSession.swift index cd4fdc4..50780d4 100644 --- a/CommandNotch/CommandNotch/Models/TerminalSession.swift +++ b/CommandNotch/CommandNotch/Models/TerminalSession.swift @@ -12,14 +12,23 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon private var keyEventMonitor: Any? private let backgroundColor = NSColor.black private let configuredShellPath: String + private let launchDirectory: String @Published var title: String = "shell" @Published var isRunning: Bool = true @Published var currentDirectory: String? - init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) { + init( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? = nil, + startImmediately: Bool = true + ) { terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) configuredShellPath = shellPath + launchDirectory = Self.resolveInitialDirectory(initialDirectory) + currentDirectory = launchDirectory super.init() terminalView.terminalDelegate = self @@ -29,7 +38,11 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon applyTheme(theme) installCommandArrowMonitor() - startShell() + if startImmediately { + startShell() + } else { + isRunning = false + } } deinit { @@ -52,12 +65,29 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon args: ["-l"], environment: nil, execName: loginExecName, - currentDirectory: NSHomeDirectory() + currentDirectory: launchDirectory ) process = proc title = shellName } + private static func resolveInitialDirectory(_ directory: String?) -> String { + normalizedDirectory(directory) ?? NSHomeDirectory() + } + + private static func normalizedDirectory(_ directory: String?) -> String? { + let trimmed = directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { + return nil + } + + if let url = URL(string: trimmed), url.isFileURL { + return url.path(percentEncoded: false) + } + + return (trimmed as NSString).expandingTildeInPath + } + private func resolveShell() -> String { let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines) if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) { @@ -145,7 +175,8 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon } func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { - currentDirectory = directory + guard let normalizedDirectory = Self.normalizedDirectory(directory) else { return } + currentDirectory = normalizedDirectory } func scrolled(source: TerminalView, position: Double) {} diff --git a/CommandNotch/CommandNotch/Models/WorkspaceController.swift b/CommandNotch/CommandNotch/Models/WorkspaceController.swift index 73eb80e..732b808 100644 --- a/CommandNotch/CommandNotch/Models/WorkspaceController.swift +++ b/CommandNotch/CommandNotch/Models/WorkspaceController.swift @@ -3,12 +3,27 @@ import Combine @MainActor protocol TerminalSessionFactoryType { - func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession + func makeSession( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? + ) -> TerminalSession } struct LiveTerminalSessionFactory: TerminalSessionFactoryType { - func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession { - TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath) + func makeSession( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? + ) -> TerminalSession { + TerminalSession( + fontSize: fontSize, + theme: theme, + shellPath: shellPath, + initialDirectory: initialDirectory + ) } } @@ -90,7 +105,8 @@ final class WorkspaceController: ObservableObject { let session = sessionFactory.makeSession( fontSize: config.fontSize, theme: config.theme, - shellPath: config.shellPath + shellPath: config.shellPath, + initialDirectory: activeTab?.currentDirectory ) titleObservers[session.id] = session.$title diff --git a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift index b81de00..8c366c7 100644 --- a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift @@ -313,7 +313,12 @@ private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigura private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType { @MainActor - func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession { + func makeSession( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? + ) -> TerminalSession { fatalError("ScreenRegistryTests should not create live terminal sessions.") } } diff --git a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift index 193d1c1..c1c2a4e 100644 --- a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift @@ -120,6 +120,64 @@ final class WorkspaceRegistryTests: XCTestCase { } } +@MainActor +final class WorkspaceControllerTests: XCTestCase { + func testNewTabUsesActiveTabCurrentDirectory() { + let factory = RecordingTerminalSessionFactory() + let controller = WorkspaceController( + summary: WorkspaceSummary(name: "Main"), + sessionFactory: factory, + settingsProvider: TestSettingsProvider(), + bootstrapDefaultTab: false + ) + + controller.newTab() + controller.activeTab?.currentDirectory = "/tmp/Raycast" + + controller.newTab() + + XCTAssertEqual(factory.requestedDirectories, [nil, "/tmp/Raycast"]) + XCTAssertEqual(controller.activeTab?.currentDirectory, "/tmp/Raycast") + XCTAssertEqual(controller.tabs.count, 2) + XCTAssertEqual(controller.activeTabIndex, 1) + } + + func testNewTabNormalizesCurrentDirectoryFileURL() { + let factory = RecordingTerminalSessionFactory() + let controller = WorkspaceController( + summary: WorkspaceSummary(name: "Main"), + sessionFactory: factory, + settingsProvider: TestSettingsProvider(), + bootstrapDefaultTab: false + ) + let expectedPath = "/tmp/Raycast Folder" + + controller.newTab() + controller.activeTab?.currentDirectory = URL(fileURLWithPath: expectedPath).absoluteString + + controller.newTab() + + XCTAssertEqual(controller.activeTab?.currentDirectory, expectedPath) + } + + func testNewTabFallsBackToHomeDirectoryWhenWorkspaceHasNoTabs() { + let factory = RecordingTerminalSessionFactory() + let controller = WorkspaceController( + summary: WorkspaceSummary(name: "Main"), + sessionFactory: factory, + settingsProvider: TestSettingsProvider(), + bootstrapDefaultTab: false + ) + + controller.newTab() + + XCTAssertEqual(factory.requestedDirectories, [nil]) + XCTAssertEqual(controller.activeTab?.currentDirectory, NSHomeDirectory()) + XCTAssertEqual(controller.tabs.count, 1) + XCTAssertEqual(controller.activeTabIndex, 0) + } +} + private final class InMemoryWorkspaceStore: WorkspaceStoreType { var savedSummaries: [WorkspaceSummary] = [] @@ -138,9 +196,35 @@ private final class TestSettingsProvider: TerminalSessionConfigurationProviding let terminalSizePresets = TerminalSizePresetStore.loadDefaults() } +private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType { + private(set) var requestedDirectories: [String?] = [] + + @MainActor + func makeSession( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? + ) -> TerminalSession { + requestedDirectories.append(initialDirectory) + return TerminalSession( + fontSize: fontSize, + theme: theme, + shellPath: shellPath, + initialDirectory: initialDirectory, + startImmediately: false + ) + } +} + private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType { @MainActor - func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession { + func makeSession( + fontSize: CGFloat, + theme: TerminalTheme, + shellPath: String, + initialDirectory: String? + ) -> TerminalSession { fatalError("WorkspaceRegistryTests should not create live terminal sessions.") } }