Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ Chop의 라벨과 오류 문구는 medium으로 통일합니다. 셸·TUI가 출
- 실제 파일 목록과 로딩/빈/오류 안내는 바로가기 아래의 별도 목록 면에 함께 둡니다. 배경은 fileListBackground(light F8F8F8/dark24262C)입니다. 버튼색을 그대로 넓은 면에 쓰면 너무 강하다는 후속 사용자 교정에 따라,버튼의 중립색을 본문 쪽으로 약35%만 반영한 연한 톤을 사용합니다. 헤더·버튼색은 유지하며 그라디언트·구분선은 추가하지 않습니다. 목록 면은 반경8pt/내부 inset8pt,바깥 좌우·하단 inset12pt로 구분합니다. 최소 패널280pt에서도 세 바로가기의 전체 이름·아이콘과 파일 행 클릭 영역이 유지돼야 합니다. 목록 자체의 선택·펼치기·키보드·스크롤·경로 드래그는 같은 NSOutlineView를 재사용합니다.
- 바로가기 아래의28pt native NSSearchField에서 현재 폴더와 하위 폴더의 파일·폴더 이름을 검색합니다. 기존14pt medium/gap8/좌우 inset12를 사용하며 최신 사용자 요청의 중립색 blur inset 경계와 부드러운 hover/focus 전환을 적용하고 실제 caret·native 검색/지우기 아이콘과 AX 이름을 유지합니다. 입력 후75ms 동안 기다린 뒤 백그라운드로 읽고,찾은 결과부터 75ms 간격으로 갱신을 제한하며 표시합니다. 검색어 편집 중에는 폴더별 목록을 최대10초·총10만 항목(빈 폴더도1개로 계산)까지 재사용하며 새로고침·폴더 이동은 캐시를 교체합니다. 새 검색·탐색·뷰 해제 때 이전 요청을 취소하며 실제 디렉터리 읽기에도 취소를 전달합니다. 검색 결과는 같은 목록 면에 상대 경로로 표시해 동명 파일을 구분하며 전체 경로는 툴팁/경로 복사/파일 URL 드래그에 보존합니다. 검색어를 지우면 일반 트리,결과 폴더를 더블클릭/Enter로 열면 해당 폴더의 일반 트리로 돌아옵니다. 새로고침은 같은 폴더의 검색어를 유지하고 목적지를 바꾸면 초기화합니다. 숨김·심볼릭 링크·패키지 하위 제외는 기존 트리 규칙을 따릅니다. 결과는 처음500개까지 표시하며 초과 안내/검색어 좁히기를 제공하고,일부 하위 폴더의 읽기 실패와루트 실패·0건·검색 중을 구분합니다. 파일 내용 검색은 현재 범위에 포함하지 않습니다.

## 로컬 서버 주소

최신 사용자 요청의 터미널별 로컬 주소를 해당 칸 헤더와 선택한 탭 바에 표시합니다. 기존40pt 헤더/14pt medium/12pt inset·gap/28pt 컨트롤과 모드별 secondaryForeground를 사용합니다. 새 색·경계·상시 설명 영역은 추가하지 않습니다. SF Symbols arrow.up.right(14pt medium)는 브라우저 열기를 뜻합니다. 서버가 없으면 컨트롤을 숨기고 경로를 복원합니다. 하나면 직접 열고 둘 이상이면 native NSMenu에 전체 URL을 표시합니다. 자동으로 브라우저나 네트워크 요청을 실행하지 않습니다.

칸 폭480pt 미만에서 주소가 있으면 경로 자리를 주소에 우선 배정하며 경로는 칸 이름 툴팁에도 유지합니다.340pt 미만에서는 `:port +N`으로 줄이고 전체 주소/동작은 AX와 툴팁에 남깁니다. 탭 바420pt 미만도 같은 압축 표시를 사용합니다. 이 임계값은 chosen이며 원본 이미지 실측이 아닙니다. 이름 버튼의 텍스트/연필은 intrinsic 폭을 유지하고, 긴 이름은 주소보다 먼저 줄입니다. 목록 팝업은 native 키보드/ESC 동작을 유지합니다.

실제 listener/셸 자식 관계를 배경에서1초 간격으로 확인하고 갱신은 현재 surface/PID가 같은 대상에만 반영합니다. 종료·재실행 시 목록은 비우며 세션 저장에 URL을 넣지 않습니다. 최초 proc_listchildpids 반환 단위와 macOS login의 제한된 BSD 조회로 주소가 누락된 실패를 실제 프로세스/통합 검사에서 발견하고 수정했습니다. 대상은 로컬 TCP이며 HTTP 여부의 확정이나 외부 Docker/daemon·원격 서버 추적은 범위 밖입니다. 현재 화면의 HTTPS 표시는 scheme 힌트로만 사용하고 대상 host/port는 native listener에서 결정합니다. 상세 검증은 [로컬 서버 관찰](design/evidence/local-servers.md)에 기록합니다.

## Elevation & Depth

기본 화면은 평면으로 유지합니다. 팝업과 확인 시트는 네이티브 AppKit을 사용합니다.
Expand Down
4 changes: 3 additions & 1 deletion Dependencies/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Chop은 macOS용 libghostty embedder API와 Metal 렌더러를 사용합니다.
이 API는 안정적인 범용 API가 아니므로 버전을 고정하고 `TerminalEngine` / `TerminalView`에서만 사용합니다.
외부용 libghostty-vt는 렌더링과 프로세스 관리를 직접 구현해야 하므로 현재 버전에는 사용하지 않습니다.

`ghostty-config.patch`는 Chop의 번들 설정 파일을 읽는 함수 하나와 아카이브 도구 어댑터 연결을 추가합니다.
`ghostty-config.patch`는 Chop의 번들 설정 파일 읽기·surface의 셸 PID 조회 함수와 아카이브 도구 어댑터 연결을 추가합니다.
사용자의 Ghostty 설정이나 프로세스 인자를 수정하지 않고 설정을 읽습니다.
최신 Apple libtool이 Zig 아카이브의 정렬되지 않은 객체를 누락하는 문제는
`scripts/ghostty-libtool.sh`에서 객체를 꺼내 재조립해 해결합니다. 완성한 라이브러리의 공개 함수도 검사합니다.
Expand All @@ -27,3 +27,5 @@ Xcode 26의 일부 arm64 SDK에서는 Zig 0.14 링크가 실패합니다.
Metal 도구가 빠져 있으면 Xcode의 공식 `MetalToolchain` 구성 요소를 내려받습니다.
Git 전송을 사용하는 `zg`와 `zigimg`는 같은 커밋의 아카이브를 미리 받아 업스트림에 고정된 Zig 콘텐츠 해시를 검증합니다.
Codeberg의 Git capability 조회가 실패하는 CI 환경에서도 패키지 버전을 바꾸지 않고 빌드하기 위한 처리입니다.

`chop_ghostty_surface_child_pid`는 IO 스레드가 publish한 atomic PID만 읽습니다. 실행 중인 터미널에 속한 TCP listener를 구분하는 용도이며 원래 버전·라이선스는 유지합니다.
66 changes: 66 additions & 0 deletions Dependencies/ghostty-config.patch
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,69 @@ diff --git a/src/build/LibtoolStep.zig b/src/build/LibtoolStep.zig
@@ -44 +44 @@
- run_step.addArgs(&.{ "libtool", "-static", "-o" });
+ run_step.addArgs(&.{ "chop-libtool", "-static", "-o" });
diff --git a/include/ghostty.h b/include/ghostty.h
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -936,6 +936,7 @@
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
bool ghostty_surface_process_exited(ghostty_surface_t);
+int32_t chop_ghostty_surface_child_pid(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig
--- a/src/termio/Exec.zig
+++ b/src/termio/Exec.zig
@@ -42,6 +42,9 @@
/// The subprocess state for our exec backend.
subprocess: Subprocess,

+// Published by IO startup for Chop; UI reads only this atomic, never Command state.
+chop_child_pid: std.atomic.Value(i32) = std.atomic.Value(i32).init(0),
+
/// Initialize the exec state. This will NOT start it, this only sets
/// up the internal state necessary to start it later.
pub fn init(
@@ -87,6 +90,7 @@
io: *termio.Termio,
td: *termio.Termio.ThreadData,
) !void {
+ errdefer self.chop_child_pid.store(0, .release);
// Start our subprocess
const pty_fds = self.subprocess.start(alloc) catch |err| {
// If we specifically got this error then we are in the forked
@@ -118,6 +122,7 @@
return error.ProcessNotStarted;
const pid = command.pid orelse
return error.ProcessNoPid;
+ self.chop_child_pid.store(pid, .release);
break :process try xev.Process.init(pid);
};
errdefer if (process) |*p| p.deinit();
@@ -197,6 +202,7 @@
}

pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
+ self.chop_child_pid.store(0, .release);
assert(td.backend == .exec);
const exec = &td.backend.exec;

diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -1519,6 +1519,14 @@
return surface.core_surface.child_exited;
}

+ // Chop extension: read the IO-published root PID without inspecting mutable subprocess state.
+ export fn chop_ghostty_surface_child_pid(surface: *Surface) i32 {
+ if (surface.core_surface.child_exited) return 0;
+ return switch (surface.core_surface.io.backend) {
+ .exec => |*exec| exec.chop_child_pid.load(.acquire),
+ };
+ }
+
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.

| 기능 | 설명 |
| --- | --- |
| 로컬 서버 주소 | 각 터미널의 실행 중인 로컬 포트를 헤더에 표시하고 브라우저로 엽니다. 여러 개면 주소 목록을 제공합니다. |
| 프로젝트별 윈도우 | 파일 메뉴나 Dock 메뉴에서 새 창을 엽니다. 프로젝트마다 셸·레이아웃·파일 트리를 따로 유지합니다. |
| 계속 터미널 추가 | 빈 칸 중앙의 `+` 또는 오른쪽 세로 영역의 `+`로 터미널을 엽니다. 네 칸을 채운 뒤에도 아래로 추가합니다. |
| 네 가지 보기 | 상단 탭, 좌우, 상하, 격자로 전환하며 기존 터미널을 모두 유지합니다. |
Expand Down Expand Up @@ -100,6 +101,21 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.
<sub>넓게 볼 때는 탭으로. 다시 나눌 때도 기존 터미널은 그대로.</sub>
</p>

## 로컬 서버 주소

터미널에서 개발 서버를 실행하면 헤더에 `localhost:3000` 같은 주소가 표시됩니다. 하나면
클릭해 기본 브라우저로 열고, 여러 개면 `+1`처럼 추가 개수와 함께 주소 목록을 제공합니다.
탭 보기에서는 선택한 터미널의 주소를 오른쪽에 표시합니다. 서버 종료·포트 변경은 약1초 간격으로 반영합니다.
좁은 칸에서는 `:3000`처럼 포트만 표시하며 전체 주소는 툴팁과 목록에 남습니다. 경로가 가려지면 칸 이름의 툴팁에서 확인합니다.

Chop이 연 셸과 그 자식 프로세스의 실제 TCP listener를 조회합니다. 루프백 또는 전체 인터페이스로
바인딩한 포트가 대상이며, 다른 터미널·외부 앱의 서버는 섞지 않습니다. 별도 권한 요청이나 서버 자동 접속은 없습니다.
브라우저는 기본 HTTP로 엽니다. 현재 화면에 같은 포트의 로컬 주소(`localhost`·루프백·전체 인터페이스)가
있으면 화면상 마지막 주소의 HTTP/HTTPS를 반영하므로, 뒤에 나온 HTTP 주소가 앞선 HTTPS보다 우선합니다.
확인한 방식은 화면에서 힌트가 사라져도 해당 서버가 종료될 때까지 유지됩니다.
TCP 포트만으로 HTTP 서비스 여부까지 판별하지는 않습니다. Docker/별도 데몬이 소유하거나 셸에서 완전히
분리된 서버, 원격 SSH 서버의 주소는 자동 표시하지 않습니다. 재실행 때 이전 주소를 저장해 보여주지 않습니다.

## 파일 트리

파일 트리는 공통 툴바 아래, 터미널 오른쪽에서 열립니다. `⌘⇧E`로 표시하거나 숨기고,
Expand Down
75 changes: 75 additions & 0 deletions Sources/Chop/LocalServerButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import AppKit

/// Shared by pane headers and the selected terminal's tab bar; opening is always user initiated.
@MainActor
final class LocalServerButton: NSButton {
private(set) var urls: [URL] = []
private var compact = false
var openURL: (URL) -> Void = { NSWorkspace.shared.open($0) }

init() {
super.init(frame: .zero)
target = self
action = #selector(openServer)
isBordered = false
bezelStyle = .inline
font = ChopStyle.font
image = NSImage(systemSymbolName: "arrow.up.right", accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: 14, weight: .medium))
imagePosition = .imageTrailing
alignment = .right
cell?.lineBreakMode = .byTruncatingMiddle
setContentCompressionResistancePriority(.required, for: .horizontal)
setContentHuggingPriority(.defaultHigh, for: .horizontal)
isHidden = true
translatesAutoresizingMaskIntoConstraints = false
heightAnchor.constraint(equalToConstant: 28).isActive = true
}

@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("Use init().") }

func update(_ urls: [URL]) {
self.urls = urls
isHidden = urls.isEmpty
guard let first = urls.first else { return }
let address =
compact
? ":\(first.port ?? 80)"
: first.absoluteString
.replacingOccurrences(of: "https://", with: "").replacingOccurrences(
of: "http://", with: "")
title = address + (urls.count > 1 ? " +\(urls.count - 1)" : "")
toolTip = urls.map(\.absoluteString).joined(separator: "\n")
setAccessibilityLabel(
urls.count == 1 ? "로컬 서버 \(first.absoluteString) 열기" : "로컬 서버 \(urls.count)개 보기")
}

func setCompact(_ value: Bool) {
guard compact != value else { return }
compact = value
update(urls)
}

@objc private func openServer() {
guard let first = urls.first else { return }
if urls.count == 1 {
openURL(first)
} else {
let menu = NSMenu()
for url in urls {
let item = menu.addItem(
withTitle: url.absoluteString, action: #selector(openItem(_:)), keyEquivalent: "")
item.target = self
item.representedObject = url
}
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.minY), in: self)
}
}

@objc private func openItem(_ sender: NSMenuItem) {
// A menu can remain open while the listener exits; do not open a removed endpoint.
guard let url = sender.representedObject as? URL, urls.contains(url) else { return }
openURL(url)
}
}
26 changes: 26 additions & 0 deletions Sources/Chop/TerminalEngine.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import ChopCore
import GhosttyKit

private func wakeupGhostty(_ userdata: UnsafeMutableRawPointer?) {
Expand Down Expand Up @@ -26,6 +27,7 @@ final class TerminalEngine: NSObject {
private(set) var appearanceMode: AppearanceMode = .light
private let clipboard: NSPasteboard
let command: String?
private var serverMonitor: Task<Void, Never>?

init(clipboard: NSPasteboard = .general, command: String? = nil) {
self.clipboard = clipboard
Expand Down Expand Up @@ -121,9 +123,12 @@ final class TerminalEngine: NSObject {
self, selector: #selector(keyboardChanged),
name: NSTextInputContext.keyboardSelectionDidChangeNotification, object: nil)
focusChanged()
monitorLocalServers()
}

func shutdown() {
serverMonitor?.cancel()
serverMonitor = nil
NotificationCenter.default.removeObserver(self)
if let app { ghostty_app_free(app) }
app = nil
Expand All @@ -133,6 +138,27 @@ final class TerminalEngine: NSObject {
Self.shared = nil
}

private func monitorLocalServers() {
serverMonitor = Task { [weak self] in
while !Task.isCancelled {
guard let self, app != nil else { return }
let targets = terminals.allObjects.map { ($0, $0.childPID) }.filter { $0.1 > 0 }
let roots = targets.map(\.1)
let scan = Task.detached(priority: .utility) { LocalServerDiscovery.scan(rootPIDs: roots) }
let result = await withTaskCancellationHandler {
await scan.value
} onCancel: {
scan.cancel()
}
guard !Task.isCancelled, app != nil else { return }
for (terminal, pid) in targets where terminal.childPID == pid {
terminal.updateLocalServers(result[pid] ?? [])
}
do { try await Task.sleep(for: .seconds(1)) } catch { return }
}
}
}

func applyAppearance(_ mode: AppearanceMode) {
guard let app, let config = configs[mode], mode != appearanceMode else { return }
appearanceMode = mode
Expand Down
34 changes: 31 additions & 3 deletions Sources/Chop/TerminalPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class TerminalPaneView: NSView {
}
}
var onChange: (() -> Void)?
var onLocalServersChange: (() -> Void)?
var localServerURLs: [URL] { terminal?.localServerURLs ?? [] }
var onFocus: (() -> Void)?
var onSplitRequested: ((SplitAxis) -> Void)?
var canSplit: ((SplitAxis) -> Bool)?
Expand Down Expand Up @@ -51,6 +53,7 @@ final class TerminalPaneView: NSView {
private let body = NSView()
private let titleButton = NSButton()
private let pathLabel = NSTextField(labelWithString: "")
private let serverButton = LocalServerButton()
private let errorLabel = NSTextField(wrappingLabelWithString: "")
private var openButton: NSButton!
private var closeButton: NSButton!
Expand Down Expand Up @@ -89,6 +92,7 @@ final class TerminalPaneView: NSView {
header.layer?.backgroundColor = mode.headerBackground.cgColor
updateNameButton()
pathLabel.textColor = mode.secondaryForeground
serverButton.contentTintColor = mode.secondaryForeground
errorLabel.textColor = mode.secondaryForeground
updateDropHighlight()
}
Expand Down Expand Up @@ -196,6 +200,10 @@ final class TerminalPaneView: NSView {
self.updateHeader()
self.onChange?()
}
view.onLocalServersChange = { [weak self] in
self?.updateServerDisplay()
self?.onLocalServersChange?()
}
body.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
Expand Down Expand Up @@ -322,7 +330,7 @@ final class TerminalPaneView: NSView {
titleButton.alignment = .left
titleButton.font = ChopStyle.font
titleButton.cell?.lineBreakMode = .byTruncatingTail
titleButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
titleButton.setContentHuggingPriority(.required, for: .horizontal)
titleButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let nameMenu = NSMenu()
nameMenu.addItem(withTitle: "이름 바꾸기…", action: #selector(renamePane), keyEquivalent: "")
Expand All @@ -347,7 +355,9 @@ final class TerminalPaneView: NSView {
self?.onDragEnded?()
}
moveHandle.isHidden = true
let row = ChopStyle.headerRow(views: [moveHandle, titleButton, pathLabel, zoomButton, close])
let row = ChopStyle.headerRow(views: [
moveHandle, titleButton, pathLabel, serverButton, zoomButton, close,
])
header.addSubview(row)
openButton = NSButton(
image: NSImage(systemSymbolName: "plus", accessibilityDescription: "터미널 열기")!, target: self,
Expand Down Expand Up @@ -389,6 +399,7 @@ final class TerminalPaneView: NSView {
}

private func updateHeader() {
updateServerDisplay()
updateMoveHandle()
moveHandle.setAccessibilityLabel("\(name) 이동")
updateNameButton()
Expand All @@ -409,12 +420,29 @@ final class TerminalPaneView: NSView {
accessibilityDescription: zoomLabel)
}

override func layout() {
super.layout()
updatePathVisibility()
}

private func updateServerDisplay() {
serverButton.update(localServerURLs)
updatePathVisibility()
}

private func updatePathVisibility() {
serverButton.setCompact(bounds.width < 340)
// Narrow panes prioritize the server; the directory remains in the name's tooltip.
let hidePath = !localServerURLs.isEmpty && bounds.width < 480
if pathLabel.isHidden != hidePath { pathLabel.isHidden = hidePath }
}

private func updateNameButton() {
titleButton.contentTintColor = engine.appearanceMode.secondaryForeground
titleButton.attributedTitle = NSAttributedString(
string: name,
attributes: [.font: ChopStyle.font, .foregroundColor: engine.appearanceMode.foreground])
titleButton.toolTip = "\(name) — 이름 바꾸기 (⌘⇧R)"
titleButton.toolTip = "\(name) — 이름 바꾸기 (⌘⇧R)\n\(workingDirectory)"
titleButton.setAccessibilityLabel("\(name) 이름 바꾸기")
}
}
Loading
Loading