diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2e7a1396a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# term244 作業メモ + +term244 は Ghostty(ターミナルエミュレータ)のフォーク。アプリ名を term244 にリブランドし、 +HTML/Markdown レンダリングタブの追加や WSL/Windows 対応を進めている。 + +## 知見ログ + +### 2026-05-22: macOS ビルドで Metal Toolchain 不足 + +- **何が起きたか**: `zig build` の `metal Ghostty (Ghostty.ir)` ステップで + `error: cannot execute tool 'metal' due to missing Metal Toolchain` が発生。 +- **なぜ起きたか**: Xcode 26 では Metal Toolchain が標準同梱されず、別ダウンロード + コンポーネントになった。Xcode 本体だけでは `metal` コンパイラが無い。 +- **どう直したか**: `xcodebuild -downloadComponent MetalToolchain` で導入。 + ビルドスクリプト(`/tmp/term244-build.sh`)に「`xcrun -sdk macosx metal --version` + で有無を確認し、無ければ自動 DL」する処理を組み込んだ。 + +### 2026-05-22: リブランドの方針(軽いリブランド) + +- `ghostty` 文字列は terminfo(`xterm-ghostty`)・C API シンボル(`ghostty_*`)・ + 設定ディレクトリ(`~/.config/ghostty`)・GTK app id にも広く存在し、変えると + 互換性が壊れる。そのため**ユーザーから見える名前だけ**を term244 に変更する。 +- 変更済み: `macos/Ghostty.xcodeproj/project.pbxproj`(PRODUCT_NAME / EXECUTABLE_NAME / + CFBundleDisplayName / PRODUCT_BUNDLE_IDENTIFIER = `com.term244.term244`)、 + `Ghostty.xcscheme` の BuildableName、`src/build/GhosttyXcodebuild.zig` の + 旧名ハードコード(`Ghostty.app` / `ghostty`)。 +- 据え置き: terminfo、C API、設定ディレクトリ、Xcode プロジェクト/スキームのファイル名・ + ターゲット名(内部名のため)。 diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fb24d0813..dbf1a15de 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -86,7 +86,7 @@ A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; - A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = term244.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -748,11 +748,11 @@ DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - EXECUTABLE_NAME = ghostty; + EXECUTABLE_NAME = term244; GCC_OPTIMIZATION_LEVEL = fast; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ghostty-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; + INFOPLIST_KEY_CFBundleDisplayName = term244; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; INFOPLIST_KEY_NSAudioCaptureUsageDescription = "A program running within Ghostty would like to access your system's audio."; @@ -779,8 +779,8 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.term244.term244; + PRODUCT_NAME = term244; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; SWIFT_VERSION = 5.0; @@ -963,7 +963,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/term244.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/term244"; }; name = Debug; }; @@ -986,7 +986,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/term244.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/term244"; }; name = Release; }; @@ -1009,7 +1009,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/term244.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/term244"; }; name = ReleaseLocal; }; @@ -1147,10 +1147,10 @@ DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - EXECUTABLE_NAME = ghostty; + EXECUTABLE_NAME = term244; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ghostty-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Ghostty[DEBUG]"; + INFOPLIST_KEY_CFBundleDisplayName = "term244[DEBUG]"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; @@ -1176,8 +1176,8 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.term244.term244.debug; + PRODUCT_NAME = term244; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1201,11 +1201,11 @@ DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - EXECUTABLE_NAME = ghostty; + EXECUTABLE_NAME = term244; GCC_OPTIMIZATION_LEVEL = fast; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ghostty-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; + INFOPLIST_KEY_CFBundleDisplayName = term244; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; @@ -1231,8 +1231,8 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.term244.term244; + PRODUCT_NAME = term244; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h"; SWIFT_VERSION = 5.0; diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 16be46d67..d088d3b4e 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -15,7 +15,7 @@ diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 67ec9ac4a..304dce4bb 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -212,6 +212,16 @@ class AppDelegate: NSObject, // Initial config loading ghosttyConfigDidChange(config: ghostty.config) + // Add the "Open Viewer Tab…" item to the File menu, right after "New Tab". + if let newTabItem = menuNewTab, let fileMenu = newTabItem.menu { + let viewerItem = NSMenuItem( + title: "Open Viewer Tab…", + action: #selector(openViewerTab(_:)), + keyEquivalent: "") + viewerItem.target = self + fileMenu.insertItem(viewerItem, at: fileMenu.index(of: newTabItem) + 1) + } + // Start our update checker. updateController.startUpdater() @@ -458,6 +468,17 @@ class AppDelegate: NSObject, var isDirectory = ObjCBool(true) guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false } + // HTML and Markdown files open in a viewer tab rather than executing. + if !isDirectory.boolValue { + let url = URL(fileURLWithPath: filename) + if ViewerController.supportedExtensions.contains(url.pathExtension.lowercased()) { + ViewerController.open( + fileURL: url, + from: TerminalController.preferredParent?.window) + return true + } + } + // Set to true if confirmation is required before starting up the // new terminal. var requiresConfirm: Bool = false @@ -962,6 +983,21 @@ class AppDelegate: NSObject, ) } + /// Opens a file picker and renders the chosen HTML/Markdown file in a viewer tab. + @IBAction func openViewerTab(_ sender: Any?) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.message = "Choose an HTML or Markdown file to view" + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + ViewerController.open( + fileURL: url, + from: TerminalController.preferredParent?.window) + } + } + @IBAction func closeAllWindows(_ sender: Any?) { TerminalController.closeAllWindows() AboutController.shared.hide() diff --git a/macos/Sources/Features/Viewer/ViewerController.swift b/macos/Sources/Features/Viewer/ViewerController.swift new file mode 100644 index 000000000..606e1c0b6 --- /dev/null +++ b/macos/Sources/Features/Viewer/ViewerController.swift @@ -0,0 +1,75 @@ +import AppKit +import SwiftUI + +/// A non-terminal tab that renders an HTML or Markdown file. +/// +/// Unlike terminal tabs (`TerminalController` / `BaseTerminalController`), this +/// is a lean `NSWindowController` that hosts a `WKWebView` through SwiftUI. Its +/// window joins the native macOS tab group of a terminal window so it appears +/// as a regular tab alongside terminal tabs. +class ViewerController: NSWindowController, NSWindowDelegate { + /// Strong references to open viewer controllers. AppKit does not retain a + /// window controller for us, so we keep them alive here while their window + /// is open and drop them in `windowWillClose`. + private static var openControllers: [ViewerController] = [] + + /// File extensions this viewer knows how to render. + static let supportedExtensions: Set = [ + "md", "markdown", "mdown", "mkd", "mkdn", "html", "htm", + ] + + private let fileURL: URL + + init(fileURL: URL) { + self.fileURL = fileURL + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 680), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + window.tabbingMode = .preferred + window.title = fileURL.lastPathComponent + window.isReleasedWhenClosed = false + + super.init(window: window) + + window.delegate = self + window.contentView = NSHostingView(rootView: ViewerView(fileURL: fileURL)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// Open `fileURL` in a new viewer tab. If `parent` is given, the viewer + /// joins that window's native tab group; otherwise it opens standalone. + @discardableResult + static func open(fileURL: URL, from parent: NSWindow? = nil) -> ViewerController { + let controller = ViewerController(fileURL: fileURL) + openControllers.append(controller) + + guard let window = controller.window else { return controller } + + if let parent, window.tabbingMode != .disallowed { + // If macOS already auto-tabbed our window, remove it first so we + // control the ordering (mirrors TerminalController.newTab). + if let group = parent.tabGroup, + group.windows.firstIndex(of: window) != nil { + group.removeWindow(window) + } + parent.addTabbedWindowSafely(window, ordered: .above) + } + + controller.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return controller + } + + // MARK: NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + Self.openControllers.removeAll { $0 === self } + } +} diff --git a/macos/Sources/Features/Viewer/ViewerView.swift b/macos/Sources/Features/Viewer/ViewerView.swift new file mode 100644 index 000000000..09d063710 --- /dev/null +++ b/macos/Sources/Features/Viewer/ViewerView.swift @@ -0,0 +1,324 @@ +import SwiftUI +import WebKit + +/// SwiftUI root view for a viewer tab. Hosts a `WKWebView` that renders the +/// given HTML or Markdown file. +struct ViewerView: View { + let fileURL: URL + + var body: some View { + ViewerWebView(fileURL: fileURL) + .ignoresSafeArea() + } +} + +/// `NSViewRepresentable` wrapper around `WKWebView`. +struct ViewerWebView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + // A viewer never needs cookies or localStorage; a non-persistent store + // skips disk I/O on init. + config.websiteDataStore = .nonPersistent() + + let webView = WKWebView(frame: .zero, configuration: config) + webView.allowsMagnification = true + load(into: webView) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + // The file URL is fixed for the lifetime of the view; nothing to do. + } + + private func load(into webView: WKWebView) { + let ext = fileURL.pathExtension.lowercased() + let dir = fileURL.deletingLastPathComponent() + + switch ext { + case "md", "markdown", "mdown", "mkd", "mkdn": + let text = (try? String(contentsOf: fileURL, encoding: .utf8)) + ?? "# Could not read file\n\n`\(fileURL.path)`" + webView.loadHTMLString(ViewerHTML.markdownPage(markdown: text), baseURL: dir) + default: + // html, htm, or anything else: render the file directly. Grant + // read access to the directory so relative assets resolve. + webView.loadFileURL(fileURL, allowingReadAccessTo: dir) + } + } +} + +/// Builds the self-contained HTML page used to render Markdown. The page embeds +/// its own CSS and a compact Markdown-to-HTML renderer, so no network access or +/// bundled resources are required. +/// +/// The Markdown source is embedded as a base64 string. base64's alphabet +/// (`A-Za-z0-9+/=`) contains no HTML-significant characters, so it can never +/// prematurely close the ` + + +"""# +} diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 81af994ca..b0a85d0ca 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -49,7 +49,7 @@ pub fn init( }; const env = try std.process.getEnvMap(b.allocator); - const app_path = b.fmt("macos/build/{s}/Ghostty.app", .{xc_config}); + const app_path = b.fmt("macos/build/{s}/term244.app", .{xc_config}); // Our step to build the Ghostty macOS app. const build = build: { @@ -143,7 +143,7 @@ pub fn init( open.has_side_effects = true; open.cwd = b.path(""); open.addArgs(&.{b.fmt( - "{s}/Contents/MacOS/ghostty", + "{s}/Contents/MacOS/term244", .{app_path}, )});