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
12 changes: 10 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,20 @@ Chop의 라벨과 오류 문구는 medium으로 통일합니다. 셸·TUI가 출

## 로컬 서버 주소

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

## 웹사이트 미리보기

사용자 요청에 따라 같은 창의 새 칸에서 웹사이트를 연다. 기존48pt 세로 영역·32pt 버튼·옅은 단일 경계를 유지하고, clone 버튼 위8pt 간격(하단52pt)에 SF Symbols globe 버튼을 둔다. 메뉴 `웹사이트 → 웹사이트 추가`와⌘⇧B도 같은 동작이다. 빈 칸 우선·모두 차면 아래 행/새 탭 추가·터미널과 합해16칸 한도를 적용하고, 로컬 주소가 이미 열려 있으면 그 칸을 선택한다.

기존40pt 칸 헤더 아래 웹 칸에만40pt 주소 행을 추가한다. 뒤로·앞으로·새로고침/중지·주소·외부 열기 순서이며 컨트롤28pt, 행 inset8pt/gap4pt와14pt medium을 쓴다(chosen/기존 native 토큰). 주소는 기존 blur inset/hover·focus 입력을 재사용한다. 헤더에 주소를 중복 표시하지 않고 이름 툴팁과 주소 입력에 보존한다. 읽는 안내·메뉴·버튼·오류는 한국어로 작성한다. 웹사이트가 소유한 글자·언어·색·간격은 제삼자 콘텐츠 예외로 두고 Chop 테마/14pt를 강제하지 않는다.

빈 주소→입력/Enter→로딩/중지→성공·뒤로/앞으로→연결 실패·주소 수정/다시 불러오기를 제공한다. 주소 입력⌘L·새로고침⌘R·페이지 확대/축소/원래 크기를 메뉴로도 제공한다. JS 알림/확인/입력은 native 시트와 한국어 버튼으로 응답한다. 웹 칸의 실제 키보드 초점을 추적하고 도구 버튼도 해당 칸으로 초점을 옮긴다. 탭/프리셋/확대/자리 바꾸기에서 같은 WKWebView와 기존 터미널을 유지한다. 종료·재실행 시 종류와 유효한 HTTP(S) 주소만 복원하며 입력 내용/DOM/방문 기록은 저장하지 않는다. 기본 WebKit 쿠키·저장소를 사용한다. file/data/실행 scheme과 URL 내 인증정보는 받지 않고 native 명령/스크립트 bridge를 노출하지 않는다. 인증서 기본 처리와 로컬 네트워크에 한정한 ATS 예외를 유지한다. 다운로드/지원하지 않는 파일은 외부 브라우저 경로를 안내한다. [첫 화면·상태·수치 검증](design/evidence/web-preview.md).

## Elevation & Depth

기본 화면은 평면으로 유지합니다. 팝업과 확인 시트는 네이티브 AppKit을 사용합니다.
Expand All @@ -96,7 +104,7 @@ Chop의 라벨과 오류 문구는 medium으로 통일합니다. 셸·TUI가 출
## Shapes

버튼 모양과 아이콘은 AppKit/SF Symbols를 사용합니다. macOS 기본 조작과 일관성을 유지하기 위한 네이티브 예외입니다.
중앙 `+`는 56×56pt 원형 클릭 영역을 사용합니다. 빈 칸의 접근성 이름은 `터미널 열기`, 편집 중 실행 칸은 `터미널 추가`입니다. 헤더 아이콘 버튼은 28×28pt 클릭 영역, 툴팁과 접근성 이름을 가집니다.
중앙 `+`는 56×56pt 원형 클릭 영역을 사용합니다. 빈 칸의 접근성 이름은 `터미널 열기`, 편집 중 실행 칸은 `칸 추가`입니다. 헤더 아이콘 버튼은 28×28pt 클릭 영역, 툴팁과 접근성 이름을 가집니다.
모드 전환 버튼은 기존 28×28pt 아이콘 버튼을 사용합니다. 레이아웃과 모드 버튼은 아이콘만 보이고, 툴팁·접근성 이름으로 의미를 제공합니다.
임의 SVG, 제품과 관계없는 일러스트나 장식 카드는 만들지 않습니다.

Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.linkedFramework("Metal"),
.linkedFramework("QuartzCore"),
.linkedFramework("IOSurface"),
.linkedFramework("WebKit"),
]
),
.testTarget(name: "ChopTests", dependencies: ["Chop"]),
Expand Down
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.
| 기능 | 설명 |
| --- | --- |
| GitHub 프로젝트 클론 | 오른쪽 추가 영역 하단에서 저장소를 클론하고, 새 창의 터미널과 파일 트리를 해당 폴더에서 엽니다. |
| 로컬 서버 주소 | 각 터미널의 실행 중인 로컬 포트를 헤더에 표시하고 브라우저로 엽니다. 여러 개면 주소 목록을 제공합니다. |
| 웹사이트 미리보기 | 터미널 옆의 새 칸에서 로컬 페이지와 웹사이트를 열고, 배치를 바꿔도 입력 상태를 유지합니다. |
| 로컬 서버 주소 | 각 터미널의 실행 중인 로컬 포트를 헤더에 표시하고 새 웹 칸으로 엽니다. 여러 개면 주소 목록을 제공합니다. |
| 프로젝트별 윈도우 | 파일 메뉴나 Dock 메뉴에서 새 창을 엽니다. 프로젝트마다 셸·레이아웃·파일 트리를 따로 유지합니다. |
| 계속 터미널 추가 | 빈 칸 중앙의 `+` 또는 오른쪽 세로 영역의 `+`로 터미널을 엽니다. 네 칸을 채운 뒤에도 아래로 추가합니다. |
| 네 가지 보기 | 상단 탭, 좌우, 상하, 격자로 전환하며 기존 터미널을 모두 유지합니다. |
Expand Down Expand Up @@ -117,18 +118,34 @@ GitHub 계정 연결·저장소 목록 탐색·새 원격 저장소 생성은
## 로컬 서버 주소

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

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

## 웹사이트 미리보기

오른쪽 추가 영역 하단의 **지구본 아이콘** 또는 `⌘⇧B`로 웹 칸을 엽니다. 주소를 입력하고
Enter를 누르면 페이지가 표시됩니다. `localhost:3000`은 HTTP, 도메인만 입력하면 HTTPS로 엽니다.
빈 칸부터 채우고, 모두 차면 아래에 행을 추가합니다. 터미널과 웹 칸을 합해 최대 16개까지 열 수 있습니다.

뒤로·앞으로·새로고침과 로딩 중지를 지원합니다. 주소 입력창과 오른쪽 위의 **브라우저에서 열기**
버튼으로 주소를 바꾸거나 외부 브라우저로 이어갈 수 있습니다. 로컬 서버가 꺼져 있으면 주소와
실행 상태를 확인한 뒤 **다시 불러오기**를 누릅니다. 다운로드는 외부 브라우저를 이용합니다.

탭·격자·확대·자리 바꾸기는 같은 웹페이지를 유지하므로 입력 내용과 실행 중인 스크립트가 이어집니다.
웹 칸을 닫거나 앱을 종료한 뒤에는 주소만 복원하고 페이지를 새로 불러옵니다. 페이지의 입력 내용과
방문 기록은 작업 공간 파일에 저장하지 않습니다. 쿠키와 웹 저장소는 WebKit의 기본 저장소를 사용합니다.
미리보기는 HTTP·HTTPS 주소를 받으며, 인증서 오류는 기본 보안 처리를 따릅니다.

## 파일 트리

파일 트리는 공통 툴바 아래, 터미널 오른쪽에서 열립니다. `⌘⇧E`로 표시하거나 숨기고,
Expand Down Expand Up @@ -189,6 +206,9 @@ Metal 도구가 없으면 Xcode의 공식 MetalToolchain 구성 요소도 준비
| --- | --- |
| 새 윈도우 / 프로젝트 폴더에서 새 윈도우 | `⌘N` / `⌘⇧N` |
| 터미널 추가 / 현재 칸 닫기 | `⌘T` / `⌘W` |
| 웹사이트 추가 / 웹 주소 입력 | `⌘⇧B` / `⌘L` |
| 웹 새로고침 / 뒤로 / 앞으로 | `⌘R` / `⌘[` / `⌘]` |
| 웹페이지 확대 / 축소 / 원래 크기 | `⌘=` / `⌘-` / `⌘0` |
| 오른쪽 / 아래로 나누기 | `⌘D` / `⌘⇧D` |
| 레이아웃 편집 | `⌘⇧L` |
| 파일 트리 보기 / 숨기기 | `⌘⇧E` |
Expand Down Expand Up @@ -219,7 +239,7 @@ Metal 도구가 없으면 Xcode의 공식 MetalToolchain 구성 요소도 준비

열린 창 목록과 선택한 창, 각 창의 레이아웃, 분할 비율, 칸 이름, 작업 경로, 열림 상태,
선택한 칸과 탭 보기 여부를 자동 저장합니다. 파일 트리의 경로·폭·표시는 창마다 기억합니다.
기존 단일 창 저장 파일도 읽습니다. 앱을 다시 열면 각 창의 열려 있던 칸에 **새 셸**을 실행합니다. 이전 프로세스, 셸 변수, 화면 내용과
웹 칸은 종류와 주소도 저장합니다. 기존 단일 창 저장 파일도 읽습니다. 앱을 다시 열면 터미널 칸에는 **새 셸**을 실행하고 웹 칸은 주소를 새로 불러옵니다. 이전 프로세스, 셸 변수, 화면 내용과
명령 기록은 저장하거나 재실행하지 않습니다. 셸의 시작 설정과 히스토리는 사용자 설정을 따릅니다.

- 작업 폴더가 없거나 열 수 없으면 알림 후 홈 폴더로 대체합니다.
Expand All @@ -233,6 +253,7 @@ Metal 도구가 없으면 Xcode의 공식 MetalToolchain 구성 요소도 준비
| --- | --- |
| 앱과 UI | Swift 6, AppKit, SF Symbols |
| 터미널 엔진 | libghostty, Metal |
| 웹사이트 미리보기 | WebKit, WKWebView |
| 레이아웃과 저장 | 분할 트리, Codable, 로컬 JSON 파일 |
| 빌드 | Swift Package Manager, 셸 스크립트 |
| 검증 | swift-format, Swift Testing, 실제 셸 통합 테스트 |
Expand Down
5 changes: 5 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,10 @@
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
41 changes: 40 additions & 1 deletion Sources/Chop/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}

@objc private func openTerminal() { workspaceWindow?.workspace.addTerminal() }
@objc private func openWebPage() { workspaceWindow?.workspace.addWebPage() }
@objc private func focusWebAddress() { workspaceWindow?.pane.browser?.focusAddress() }
@objc private func reloadWebPage() { workspaceWindow?.pane.browser?.reload() }
@objc private func webBack() { workspaceWindow?.pane.browser?.goBack() }
@objc private func webForward() { workspaceWindow?.pane.browser?.goForward() }
@objc private func webZoomIn() { workspaceWindow?.pane.browser?.changeZoom(by: 0.25) }
@objc private func webZoomOut() { workspaceWindow?.pane.browser?.changeZoom(by: -0.25) }
@objc private func webResetZoom() { workspaceWindow?.pane.browser?.resetZoom() }
@objc private func openWebInBrowser() { workspaceWindow?.pane.browser?.openInBrowser() }
@objc private func closeTerminal() { workspaceWindow?.workspace.requestCloseFocused() }
@objc private func splitRight() { workspaceWindow?.workspace.splitRight() }
@objc private func splitDown() { workspaceWindow?.workspace.splitDown() }
Expand Down Expand Up @@ -181,6 +190,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
else { return false }
switch menuItem.action {
case #selector(openTerminal): return workspace.canAddTerminal
case #selector(openWebPage): return workspace.canAddTerminal
case #selector(focusWebAddress), #selector(reloadWebPage), #selector(webZoomIn),
#selector(webZoomOut), #selector(webResetZoom), #selector(openWebInBrowser):
return workspace.focusedPane.browser != nil
case #selector(webBack): return workspace.focusedPane.browser?.canGoBack == true
case #selector(webForward): return workspace.focusedPane.browser?.webView.canGoForward == true
case #selector(toggleFileTree):
menuItem.title =
workspaceWindow?.content.isFileTreeVisible == true
Expand All @@ -195,7 +210,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
case #selector(swapDown): return workspace.canSwapFocusedPane(.down)
case #selector(closeTerminal):
menuItem.title =
workspace.isTabbed ? "탭 닫기" : workspace.focusedPane.isRunning ? "터미널 닫기" : "빈 칸 제거"
workspace.isTabbed
? "탭 닫기"
: workspace.focusedPane.browser != nil
? "웹사이트 닫기"
: workspace.focusedPane.isRunning ? "터미널 닫기" : "빈 칸 제거"
return workspace.focusedPane.isRunning || workspace.layout.paneIDs.count > 1
default: return true
}
Expand Down Expand Up @@ -296,6 +315,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
terminalItem.submenu = terminalMenu
mainMenu.addItem(terminalItem)

let webItem = NSMenuItem()
let webMenu = NSMenu(title: "웹사이트")
for (title, action, key, modifiers) in [
("새 칸에서 웹사이트 열기", #selector(openWebPage), "b", NSEvent.ModifierFlags([.command, .shift])),
("주소 입력", #selector(focusWebAddress), "l", .command),
("새로고침", #selector(reloadWebPage), "r", .command),
("뒤로", #selector(webBack), "[", .command),
("앞으로", #selector(webForward), "]", .command),
("웹페이지 확대", #selector(webZoomIn), "=", .command),
("웹페이지 축소", #selector(webZoomOut), "-", .command),
("웹페이지 원래 크기", #selector(webResetZoom), "0", .command),
("브라우저에서 열기", #selector(openWebInBrowser), "", .command),
] {
let item = webMenu.addItem(withTitle: title, action: action, keyEquivalent: key)
item.target = self
item.keyEquivalentModifierMask = modifiers
}
webItem.submenu = webMenu
mainMenu.addItem(webItem)

let editItem = NSMenuItem()
let editMenu = NSMenu(title: "편집")
editMenu.addItem(withTitle: "복사", action: #selector(TerminalView.copy(_:)), keyEquivalent: "c")
Expand Down
30 changes: 28 additions & 2 deletions Sources/Chop/LocalServerButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ final class LocalServerButton: NSButton {
private(set) var urls: [URL] = []
private var compact = false
var openURL: (URL) -> Void = { NSWorkspace.shared.open($0) }
var opensInPreview = false {
didSet {
image = NSImage(
systemSymbolName: opensInPreview ? "globe" : "arrow.up.right", accessibilityDescription: nil
)?
.withSymbolConfiguration(.init(pointSize: 14, weight: .medium))
}
}

init() {
super.init(frame: .zero)
Expand Down Expand Up @@ -40,9 +48,22 @@ final class LocalServerButton: NSButton {
.replacingOccurrences(of: "https://", with: "").replacingOccurrences(
of: "http://", with: "")
title = address + (urls.count > 1 ? " +\(urls.count - 1)" : "")
toolTip = urls.map(\.absoluteString).joined(separator: "\n")
toolTip =
urls.map(\.absoluteString).joined(separator: "\n")
+ (opensInPreview ? "\n클릭: 새 칸에서 미리보기 · 우클릭: 브라우저에서 열기" : "")
setAccessibilityLabel(
urls.count == 1 ? "로컬 서버 \(first.absoluteString) 열기" : "로컬 서버 \(urls.count)개 보기")
urls.count == 1
? "로컬 서버 \(first.absoluteString) \(opensInPreview ? "새 칸에서 미리보기" : "열기")"
: "로컬 서버 \(urls.count)개 보기")
let externalMenu = NSMenu()
for url in urls {
let item = externalMenu.addItem(
withTitle: urls.count == 1 ? "브라우저에서 열기" : "\(url.absoluteString) 브라우저에서 열기",
action: #selector(openExternal(_:)), keyEquivalent: "")
item.target = self
item.representedObject = url
}
menu = externalMenu
}

func setCompact(_ value: Bool) {
Expand Down Expand Up @@ -72,4 +93,9 @@ final class LocalServerButton: NSButton {
guard let url = sender.representedObject as? URL, urls.contains(url) else { return }
openURL(url)
}

@objc private func openExternal(_ sender: NSMenuItem) {
guard let url = sender.representedObject as? URL, urls.contains(url) else { return }
NSWorkspace.shared.open(url)
}
}
Loading
Loading