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
16 changes: 16 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ 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건·검색 중을 구분합니다. 파일 내용 검색은 현재 범위에 포함하지 않습니다.

## 최근 프로젝트와 작업 구성

`파일 → 최근 프로젝트`에서 최근에 연 폴더 최대12개를 새 창으로 연다. 경로를 표시해 동명 폴더를 구분하고,
긴 경로는 메뉴에서 가운데를 줄이되 전체 경로를 툴팁에 보존한다. `목록 지우기`는 이력만 지우며 저장한 구성과 프로젝트 파일은 유지한다.
기존 창의 자동 복원은 최근 목록의 순서를 바꾸지 않는다. 새 창·폴더 선택·GitHub 클론·구성 불러오기는 방문을 기록한다.

`파일 → 작업 구성 → 현재 구성 저장…`(⌘⇧S)에서1–64자 이름으로 배치·칸 이름·작업 경로·웹 URL·선택·탭 보기와 파일 트리의 폴더·표시 여부·너비를 저장한다.
같은 프로젝트 안에서 이름을 대소문자 구분 없이 비교하고 덮어쓰기는 대상을 확인한다. 구성은 프로젝트마다20개, 전체100개까지다.
현재 프로젝트의 저장 구성을 고르면 새 window ID로 새 셸과 웹 칸을 열고 기존 창의 프로세스·입력을 유지한다.
명령·환경변수·터미널 내용·웹 입력은 저장하지 않는다. `구성 삭제…`는 저장 항목만 지우며 열린 창과 파일은 유지한다.

새 상시 패널·rail 버튼·색·경계는 추가하지 않는다. 기존 native 파일 메뉴/NSAlert를 사용하고 이름 입력은 기존14pt medium·300×28pt blur inset을 재사용한다(existing-token).
파일 트리 너비는 기존 복원 범위280–600pt를 따른다. 프로젝트 폴더가 없으면 경로를 알리고 새 창을 만들지 않는다.
`projects.json`은 세션 파일과 별도이며 매번 기존 형식을 읽고 검증한 뒤 원자적으로 쓴다. 손상·새 버전·초과 크기·쓰기 실패에서 기존 파일을 보존한다.
문구는 한국어로 작성하고 저장 오류 때 이름을 유지해 수정·재시도할 수 있게 한다. [검증 기록](design/evidence/project-library.md).

## 로컬 서버 주소

최신 사용자 요청의 터미널별 로컬 주소를 해당 칸 헤더와 선택한 탭 바에 표시합니다. 기존40pt 헤더/14pt medium/12pt inset·gap/28pt 컨트롤과 모드별 secondaryForeground를 사용합니다. 새 색·경계·상시 설명 영역은 추가하지 않습니다. SF Symbols globe(14pt medium)는 같은 창의 새 칸에서 미리보기를 뜻하며 우클릭의 `브라우저에서 열기`도 제공합니다. 서버가 없으면 컨트롤을 숨기고 경로를 복원합니다. 하나면 직접 열고 둘 이상이면 native NSMenu에 전체 URL을 표시합니다. 주소를 누르기 전에는 자동 접속하지 않습니다.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.
| 웹사이트 미리보기 | 터미널 옆의 새 칸에서 로컬 페이지와 웹사이트를 열고, 배치를 바꿔도 입력 상태를 유지합니다. |
| 로컬 서버 주소 | 각 터미널의 실행 중인 로컬 포트를 헤더에 표시하고 새 웹 칸으로 엽니다. 여러 개면 주소 목록을 제공합니다. |
| 프로젝트별 윈도우 | 파일 메뉴나 Dock 메뉴에서 새 창을 엽니다. 프로젝트마다 셸·레이아웃·파일 트리를 따로 유지합니다. |
| 최근 프로젝트와 작업 구성 | 최근 폴더를 다시 열거나, 이름 붙여 저장한 배치·작업 경로·웹 주소를 새 창에서 불러옵니다. |
| 계속 터미널 추가 | 빈 칸 중앙의 `+` 또는 오른쪽 세로 영역의 `+`로 터미널을 엽니다. 네 칸을 채운 뒤에도 아래로 추가합니다. |
| 네 가지 보기 | 상단 탭, 좌우, 상하, 격자로 전환하며 기존 터미널을 모두 유지합니다. |
| 자유로운 배치 | 분할 경계를 드래그해 크기를 바꾸고, 편집 모드에서 칸을 끌어 자리를 바꿉니다. |
Expand Down Expand Up @@ -230,6 +231,13 @@ Metal 도구가 없으면 Xcode의 공식 MetalToolchain 구성 요소도 준비

## 데이터 저장과 복원

`파일 → 최근 프로젝트`에서 최근에 연 폴더12개를 다시 열 수 있습니다. `목록 지우기`는 최근 이력만 지웁니다.
`파일 → 작업 구성 → 현재 구성 저장…`(`⌘⇧S`)에서 현재 배치·칸 이름·작업 경로·웹 주소와 파일 트리 설정을
이름 붙여 저장하세요. 같은 메뉴에서 구성을 고르면 **새 창**에서 열립니다. 현재 창의 작업은 유지합니다.
구성은 프로젝트마다20개, 전체100개까지 저장할 수 있고 `구성 삭제…`에서 정리합니다.
최근 목록과 구성은 `~/Library/Application Support/Chop/projects.json`에 세션과 별도로 저장합니다.
기록 파일을 읽거나 검증하지 못하면 덮어쓰지 않습니다. 실행 명령과 환경변수는 구성에 포함하지 않습니다.

작업 공간은 Mac 안에 JSON 파일로 저장합니다.

```text
Expand Down
10 changes: 9 additions & 1 deletion Sources/Chop/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import ChopCore
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private var workspaceWindow: WorkspaceWindowController? { windowManager.active }
private lazy var windowManager = WorkspaceWindowManager(engine: engine)
private lazy var projectLibrary = ProjectLibraryController(
manager: windowManager,
store: ProjectLibraryStore(
fileURL: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Chop/projects.json")))
private var projectPanel: NSOpenPanel?
private let engine = TerminalEngine()
private let session = SessionCoordinator(
Expand All @@ -28,12 +33,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
return
}
configureMainMenu()
projectLibrary.load()
let saved = session.load()
windowManager.onAppearanceChangeRequested = { [weak self] mode in self?.applyAppearance(mode) }
if let saved, !saved.windows.isEmpty {
for snapshot in saved.windows {
windowManager.open(
snapshot: snapshot, legacyDefaults: session.migratedLegacyWorkspace ? .standard : nil)
snapshot: snapshot, legacyDefaults: session.migratedLegacyWorkspace ? .standard : nil,
recordVisit: false)
}
if let id = saved.focusedWindowID { windowManager.activate(id) }
} else {
Expand Down Expand Up @@ -256,6 +263,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
let clone = fileMenu.addItem(
withTitle: "GitHub 프로젝트 클론…", action: #selector(cloneProject), keyEquivalent: "")
clone.target = self
projectLibrary.install(in: fileMenu)
fileItem.submenu = fileMenu
mainMenu.addItem(fileItem)

Expand Down
235 changes: 235 additions & 0 deletions Sources/Chop/ProjectLibraryController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import AppKit
import ChopCore

@MainActor
final class ProjectLibraryController: NSObject, NSMenuDelegate, NSMenuItemValidation {
let recentMenu = NSMenu(title: "최근 프로젝트")
let configurationMenu = NSMenu(title: "작업 구성")
private let manager: WorkspaceWindowManager
private let store: ProjectLibraryStore
private(set) var library = ProjectLibrary()
private var loadFailed = false
private var reportedVisitFailure = false

init(manager: WorkspaceWindowManager, store: ProjectLibraryStore) {
self.manager = manager
self.store = store
super.init()
recentMenu.delegate = self
configurationMenu.delegate = self
manager.onProjectOpened = { [weak self] path in self?.recordVisit(path) }
}

func install(in menu: NSMenu) {
for submenu in [recentMenu, configurationMenu] {
let item = menu.addItem(withTitle: submenu.title, action: nil, keyEquivalent: "")
item.submenu = submenu
menuNeedsUpdate(submenu)
}
}

func load(reportFailure: Bool = true) {
do {
library = try store.load()
loadFailed = false
reportedVisitFailure = false
} catch {
loadFailed = true
if reportFailure { show(error, title: "프로젝트 기록을 불러오지 못했어요") }
}
}

private func recordVisit(_ path: String) {
do {
library = try store.update { try $0.visit(path) }
reportedVisitFailure = false
} catch {
guard !reportedVisitFailure else { return }
reportedVisitFailure = true
show(error, title: "최근 프로젝트를 저장하지 못했어요")
}
}

func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
if menu === recentMenu {
for path in library.recentProjects {
let item = add(
recentTitle(path), to: menu,
action: #selector(openRecent(_:)))
item.representedObject = path
item.toolTip = path
}
if library.recentProjects.isEmpty {
placeholder(loadFailed ? "프로젝트 기록을 읽지 못했어요" : "최근 프로젝트가 없어요", in: menu)
}
menu.addItem(.separator())
add("목록 지우기", to: menu, action: #selector(clearRecent))
} else {
let save = add("현재 구성 저장…", to: menu, action: #selector(saveCurrent))
save.keyEquivalent = "s"
save.keyEquivalentModifierMask = [.command, .shift]
menu.addItem(.separator())
let entries = manager.active.map { library.configurations(for: $0.projectDirectory) } ?? []
if entries.isEmpty { placeholder("저장한 구성이 없어요", in: menu) }
for entry in entries {
let item = add(entry.name, to: menu, action: #selector(openConfiguration(_:)))
item.representedObject = entry.id.uuidString
item.toolTip = "새 윈도우에서 열기 — \(entry.projectDirectory)"
}
menu.addItem(.separator())
add("구성 삭제…", to: menu, action: #selector(deleteConfiguration))
}
if loadFailed {
menu.addItem(.separator())
add("프로젝트 기록 다시 불러오기", to: menu, action: #selector(reloadLibrary))
}
}

private func recentTitle(_ path: String) -> String {
let abbreviated = (path as NSString).abbreviatingWithTildeInPath
let title = String(
abbreviated.map { character in
character.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
? Character(" ") : character
})
return title.count > 100 ? String(title.prefix(24)) + "…" + String(title.suffix(75)) : title
}

@discardableResult
private func add(_ title: String, to menu: NSMenu, action: Selector) -> NSMenuItem {
let item = menu.addItem(withTitle: title, action: action, keyEquivalent: "")
item.target = self
return item
}

private func placeholder(_ title: String, in menu: NSMenu) {
let item = menu.addItem(withTitle: title, action: nil, keyEquivalent: "")
item.isEnabled = false
}

@objc private func reloadLibrary() { load() }

@objc private func clearRecent() {
do { library = try store.update { $0.clearRecentProjects() } } catch {
show(error, title: "최근 목록을 지우지 못했어요")
}
}

@objc private func openRecent(_ sender: NSMenuItem) {
guard let path = sender.representedObject as? String, directoryExists(path) else { return }
manager.open(directory: path)
}

@objc private func openConfiguration(_ sender: NSMenuItem) {
guard let value = sender.representedObject as? String,
let entry = library.configurations.first(where: { $0.id.uuidString == value }),
directoryExists(entry.projectDirectory)
else { return }
manager.open(configuration: entry)
}

private func directoryExists(_ path: String) -> Bool {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue
else {
let alert = NSAlert()
alert.messageText = "프로젝트 폴더를 찾지 못했어요"
alert.informativeText = "\(path)\n폴더 위치를 확인한 뒤 다시 열어 주세요."
alert.addButton(withTitle: "확인")
alert.runModal()
return false
}
return true
}

@objc private func saveCurrent() {
guard let controller = manager.active, controller.window?.attachedSheet == nil else { return }
let field = InsetTextField(string: "")
field.frame = NSRect(x: 0, y: 0, width: 300, height: 28)
field.font = ChopStyle.font
field.placeholderString = "구성 이름"
field.setAccessibilityLabel("작업 구성 이름")
let alert = NSAlert()
alert.messageText = "작업 구성 저장"
alert.informativeText = "배치와 폴더, 웹 주소를 저장해요. 실행 중인 명령은 저장하지 않아요."
alert.accessoryView = field
alert.addButton(withTitle: "취소").keyEquivalent = "\u{1b}"
alert.addButton(withTitle: "저장").keyEquivalent = "\r"
alert.window.initialFirstResponder = field
while alert.runModal() == .alertSecondButtonReturn {
do {
let name = try ProjectLibrary.configurationName(field.stringValue)
let current = try store.load()
let existing = current.configuration(named: name, in: controller.projectDirectory)
if let existing, !confirmReplacement(existing.name) { continue }
library = try store.update {
try $0.saveConfiguration(
name: name, projectDirectory: controller.projectDirectory,
workspace: controller.workspace.snapshot(),
fileBrowser: controller.content.projectSettings,
replacing: existing?.id)
}
break
} catch { alert.informativeText = error.localizedDescription }
}
controller.workspace.focusedPane.focus()
}

private func confirmReplacement(_ name: String) -> Bool {
let alert = NSAlert()
alert.messageText = "‘\(name)’ 구성을 덮어쓸까요?"
alert.informativeText = "저장한 배치와 폴더, 웹 주소가 현재 구성으로 바뀝니다."
alert.addButton(withTitle: "취소")
alert.addButton(withTitle: "덮어쓰기")
return alert.runModal() == .alertSecondButtonReturn
}

@objc private func deleteConfiguration() {
guard let controller = manager.active else { return }
defer { controller.workspace.focusedPane.focus() }
let entries = library.configurations(for: controller.projectDirectory)
guard !entries.isEmpty else { return }
let picker = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 300, height: 28))
picker.font = ChopStyle.font
picker.setAccessibilityLabel("삭제할 작업 구성")
picker.addItems(withTitles: entries.map(\.name))
let alert = NSAlert()
alert.messageText = "작업 구성 삭제"
alert.informativeText = "저장한 구성만 삭제해요. 열린 창과 프로젝트 파일은 유지됩니다."
alert.accessoryView = picker
alert.addButton(withTitle: "취소")
alert.addButton(withTitle: "삭제")
guard alert.runModal() == .alertSecondButtonReturn,
entries.indices.contains(picker.indexOfSelectedItem)
else { return }
do {
let entry = entries[picker.indexOfSelectedItem]
library = try store.update {
try $0.removeConfiguration(entry.id, in: controller.projectDirectory)
}
} catch { show(error, title: "작업 구성을 삭제하지 못했어요") }
}

func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(clearRecent) { return !library.recentProjects.isEmpty }
if item.action == #selector(saveCurrent) {
return manager.active?.window?.isVisible == true
&& manager.active?.window?.attachedSheet == nil
}
if item.action == #selector(deleteConfiguration) {
return manager.active.map { !library.configurations(for: $0.projectDirectory).isEmpty }
?? false
}
return true
}

private func show(_ error: Error, title: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = "\(error.localizedDescription)\n\(store.fileURL.path)"
alert.addButton(withTitle: "확인")
alert.runModal()
}
}
6 changes: 6 additions & 0 deletions Sources/Chop/WorkspaceSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ final class WorkspaceSplitView: NSSplitView, NSSplitViewDelegate {
private var headerBackground = NSColor.white
private(set) var isFileTreeVisible: Bool

var projectSettings: ProjectFileBrowserSettings {
ProjectFileBrowserSettings(
directory: fileBrowser.rootURL.path, isVisible: isFileTreeVisible,
width: Double(min(600, max(Self.fileTreeMinimumWidth, browserWidth))))
}

override var dividerThickness: CGFloat { isFileTreeVisible ? 1 : 0 }

var minimumContentSize: CGSize {
Expand Down
Loading
Loading