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
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Chop의 라벨과 오류 문구는 medium으로 통일합니다. 셸·TUI가 출
- 오른쪽 패널은 기본300pt, 최소280pt이며 네이티브 경계로 폭을 조절합니다. 닫으면 경계도 없애고 canvas 전체 폭을 터미널에 돌려줍니다. 화면에 최소 폭이 들어가지 않으면 열기를 거절하고, 초기 복원에서는 패널을 접습니다.
- 파일 트리의 경로 입력·홈/다운로드/문서 바로가기·파일 목록은 하나의 탐색 영역입니다. 바깥 inset12/gap8, 한 줄 경로 입력28pt, 파일 행28pt, 들여쓰기16pt, 시스템14pt medium 글꼴을 유지합니다. 헤더 버튼은 기존28pt 아이콘입니다. 후속 사용자 요청의 `design/references/file-shortcuts-row-user.png` / `layout-edit-button-user.png`에 따라 바로가기는 기존 세로108pt 묶음을 대체하는 가로 한 줄의 개별 native rounded 버튼입니다. 높이28pt/gap8,기존14pt medium 글자와 SF house/arrow.down.circle/doc14pt 아이콘을 사용하고,아이콘·글자는 native image-leading 배치로 함께 누릅니다. 전체 이름과 경로 툴팁·AX 이름은 유지합니다. 사용자 지정 행 배치가 이전 Finder 세로 열 정렬 계약을 대체하며 이미지는 image-px 관찰 자료입니다.
- 실제 파일 목록과 로딩/빈/오류 안내는 바로가기 아래의 별도 목록 면에 함께 둡니다. 배경은 fileListBackground(light F8F8F8/dark24262C)입니다. 버튼색을 그대로 넓은 면에 쓰면 너무 강하다는 후속 사용자 교정에 따라,버튼의 중립색을 본문 쪽으로 약35%만 반영한 연한 톤을 사용합니다. 헤더·버튼색은 유지하며 그라디언트·구분선은 추가하지 않습니다. 목록 면은 반경8pt/내부 inset8pt,바깥 좌우·하단 inset12pt로 구분합니다. 최소 패널280pt에서도 세 바로가기의 전체 이름·아이콘과 파일 행 클릭 영역이 유지돼야 합니다. 목록 자체의 선택·펼치기·키보드·스크롤·경로 드래그는 같은 NSOutlineView를 재사용합니다.
- 바로가기 아래의28pt native NSSearchField에서 현재 폴더와 하위 폴더의 파일·폴더 이름을 검색합니다. 기존14pt medium/gap8/좌우 inset12를 사용하며 입력 포커스 테두리 없이 실제 caret·native 검색/지우기 아이콘과 AX 이름을 유지합니다. 입력 후200ms 동안 기다린 뒤 백그라운드로 읽고,새 검색·탐색·뷰 해제 때 이전 요청을 취소합니다. 검색 결과는 같은 목록 면에 상대 경로로 표시해 동명 파일을 구분하며 전체 경로는 툴팁/경로 복사/파일 URL 드래그에 보존합니다. 검색어를 지우면 일반 트리,결과 폴더를 더블클릭/Enter로 열면 해당 폴더의 일반 트리로 돌아옵니다. 새로고침은 같은 폴더의 검색어를 유지하고 목적지를 바꾸면 초기화합니다. 숨김·심볼릭 링크·패키지 하위 제외는 기존 트리 규칙을 따릅니다. 결과는 처음500개까지 표시하며 초과 안내/검색어 좁히기를 제공하고,일부 하위 폴더의 읽기 실패와루트 실패·0건·검색 중을 구분합니다. 파일 내용 검색은 현재 범위에 포함하지 않습니다.

## Elevation & Depth

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.
| 한 칸에 집중 | 현재 칸을 크게 보거나 원래 배치로 돌아갑니다. 다른 셸의 작업은 계속됩니다. |
| 라이트·다크 모드 | 상단의 달·해 아이콘으로 전환합니다. 터미널 색상도 함께 바뀌며 선택을 기억합니다. |
| 오른쪽 파일 트리 | 경로 입력, macOS 폴더 선택기, 홈·다운로드·문서 바로가기로 폴더를 탐색합니다. |
| 파일 이름 검색 | 현재 폴더와 하위 폴더에서 파일·폴더를 찾고, 같은 이름은 상대 경로로 구분합니다. |
| 경로 드래그 입력 | 파일이나 폴더를 터미널에 놓으면 셸 인자로 인용한 경로를 입력합니다. Enter는 보내지 않습니다. |
| 작업 공간 복원 | 배치·이름·경로·선택한 칸을 저장하고, 다음 실행 때 새 셸로 엽니다. |

Expand Down Expand Up @@ -101,9 +102,13 @@ libghostty 엔진과 Metal 렌더러를 사용합니다.
선택기를 열어 탐색할 폴더를 지정합니다.

폴더를 펼칠 때 하위 항목을 읽고, 폴더부터 정렬합니다. 숨김 항목은 제외하며 심볼릭 링크와
앱 패키지는 펼치지 않습니다. 파일을 더블 클릭하면 Finder에서 선택하고, 우클릭 메뉴나
앱 패키지는 펼치지 않습니다. 파일을 더블 클릭하거나 Enter를 누르면 Finder에서 선택하고, 우클릭 메뉴나
`⌘C`로 경로를 복사합니다. 실행 중인 터미널 본문으로 드래그해 경로를 넣을 수도 있습니다.

바로가기 아래 검색창에 이름을 입력하면 하위 폴더까지 검색합니다. 결과는 상대 경로로 표시하며,
검색어를 지우면 원래 트리로 돌아옵니다. 결과 폴더를 더블 클릭하거나 Enter를 누르면 그 폴더를
엽니다. 결과가 500개를 넘으면 검색어를 좁힐 수 있도록 안내합니다.

파일 트리의 탐색 경로와 터미널의 작업 경로는 독립적입니다. 마지막 폴더와 패널 표시 여부,
폭은 다음 실행에도 유지됩니다. 읽기에 실패하면 경로 수정·폴더 선택·바로가기·새로고침으로
다시 시도할 수 있습니다.
Expand Down
121 changes: 104 additions & 17 deletions Sources/Chop/FileBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ private final class FileBrowserNode {
var task: Task<Void, Never>?
lazy var placeholder = FileBrowserNode(message: "불러오는 중…")

init(entry: FileTreeEntry) {
init(entry: FileTreeEntry, message: String? = nil) {
self.entry = entry
message = entry.name
self.message = message ?? entry.name
}

init(message: String) {
Expand All @@ -30,16 +30,28 @@ private final class FileBrowserNode {
private final class FileOutlineView: NSOutlineView {
var onCopyPath: (() -> Void)?
@objc func copy(_ sender: Any?) { onCopyPath?() }

override func keyDown(with event: NSEvent) {
// Return and keypad Enter activate the selected result just like a double-click.
if [36, 76].contains(event.keyCode),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty
{
sendAction(doubleAction, to: target)
} else {
super.keyDown(with: event)
}
}
}

@MainActor
final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDelegate,
NSMenuDelegate, NSMenuItemValidation
NSMenuDelegate, NSMenuItemValidation, NSSearchFieldDelegate
{
typealias Loader = @Sendable (URL) async throws -> [FileTreeEntry]
private(set) var rootURL: URL
private(set) var isLoading = false
private(set) var loadError: String?
private(set) var searchQuery = ""
private let defaults: UserDefaults
private let loader: Loader
private var generation = UUID()
Expand All @@ -48,6 +60,7 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
private let header = NSView()
private let titleLabel = NSTextField(labelWithString: "파일")
private let pathField = NSTextField()
private let searchField = NSSearchField()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let outline = FileOutlineView()
private let scrollView = NSScrollView()
Expand Down Expand Up @@ -80,6 +93,8 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("Use init(initialURL:defaults:loader:).") }

deinit { rootTask?.cancel() }

func applyAppearance(_ mode: AppearanceMode) {
appearance = mode.appearance
layer?.backgroundColor = mode.background.cgColor
Expand All @@ -92,38 +107,83 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg

/// Each navigation replaces its generation so delayed reads cannot overwrite a newer folder.
func navigate(to directory: URL) {
rootURL = directory.standardizedFileURL
pathField.stringValue = rootURL.path
pathField.toolTip = rootURL.path
searchField.stringValue = ""
searchQuery = ""
reloadContents()
}

func search(for query: String) {
// Native edits already own the text; avoid resetting the field editor during IME composition.
if searchField.stringValue != query { searchField.stringValue = query }
let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized != searchQuery else { return }
searchQuery = normalized
reloadContents(debounce: true)
}

private func reloadContents(debounce: Bool = false) {
rootTask?.cancel()
for node in roots { node.cancelLoads() }
generation = UUID()
let request = generation
rootURL = directory.standardizedFileURL
pathField.stringValue = rootURL.path
pathField.toolTip = rootURL.path
let query = searchQuery
roots = []
loadError = nil
isLoading = true
showStatus("불러오는 중…")
showStatus(query.isEmpty ? "불러오는 중…" : "검색 중…")
outline.setAccessibilityLabel(query.isEmpty ? "파일 트리" : "파일 검색 결과")
outline.reloadData()
let loader = loader
let location = rootURL
rootTask = Task { [weak self] in
do {
let entries = try await loader(location)
if debounce && !query.isEmpty { try await Task.sleep(for: .milliseconds(200)) }
let rows: [(entry: FileTreeEntry, message: String)]
let status: String?
if query.isEmpty {
let entries = try await loader(location)
rows = entries.map { ($0, $0.name) }
status = entries.isEmpty ? "빈 폴더예요" : nil
} else {
let result = try await FileTreeSearch.search(
at: location, matching: query, reader: loader)
rows = result.matches.map { ($0.entry, $0.relativePath) }
status = Self.searchStatus(for: result)
}
guard let self, generation == request, !Task.isCancelled else { return }
roots = entries.map(FileBrowserNode.init(entry:))
roots = rows.map { FileBrowserNode(entry: $0.entry, message: $0.message) }
isLoading = false
defaults.set(location.path, forKey: "fileTreeRootPath")
outline.reloadData()
showStatus(entries.isEmpty ? "빈 폴더예요" : nil)
showStatus(status)
} catch {
guard let self, generation == request, !Task.isCancelled else { return }
isLoading = false
loadError = error.localizedDescription
showStatus("폴더를 열 수 없어요.\n\(error.localizedDescription)")
let message = query.isEmpty ? "폴더를 열 수 없어요." : "검색할 수 없어요."
showStatus("\(message)\n\(error.localizedDescription)")
}
}
}

private static func searchStatus(for result: FileTreeSearchResult) -> String {
let count = result.matches.count
var status = count == 0 ? "검색 결과가 없어요" : "\(count)개 결과"
if result.isTruncated { status = "처음 \(count)개 결과 · 검색어를 좁혀 주세요." }
if result.skippedDirectories > 0 { status += "\n일부 폴더를 읽지 못했어요." }
return status
}

func controlTextDidChange(_ notification: Notification) {
guard notification.object as? NSSearchField === searchField else { return }
search(for: searchField.stringValue)
}

@objc private func searchChanged() { search(for: searchField.stringValue) }

private func showStatus(_ text: String?) {
statusLabel.stringValue = text ?? ""
statusLabel.isHidden = text == nil
Expand All @@ -137,7 +197,18 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
}
}

@objc private func refresh() { goToPath() }
@objc private func refresh() {
do {
let directory = try FileTree.directoryURL(for: pathField.stringValue, relativeTo: rootURL)
if directory.path == rootURL.path {
reloadContents()
} else {
navigate(to: directory)
}
} catch {
showStatus(error.localizedDescription)
}
}

@objc private func visitShortcut(_ sender: NSButton) {
guard quickLocations.indices.contains(sender.tag) else { return }
Expand Down Expand Up @@ -169,7 +240,9 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
let node = outline.item(atRow: outline.selectedRow) as? FileBrowserNode
else { return }
if entry.isDirectory {
if outline.isItemExpanded(node) {
if !searchQuery.isEmpty {
navigate(to: entry.url)
} else if outline.isItemExpanded(node) {
outline.collapseItem(node)
} else {
outline.expandItem(node)
Expand Down Expand Up @@ -213,7 +286,9 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
}

func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
guard let node = item as? FileBrowserNode, node.entry?.isDirectory == true else { return false }
guard searchQuery.isEmpty, let node = item as? FileBrowserNode,
node.entry?.isDirectory == true
else { return false }
return node.children?.isEmpty != true
}

Expand All @@ -238,7 +313,7 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
do {
let entries = try await loader(entry.url)
guard let self, let node, generation == request, !Task.isCancelled else { return }
node.children = entries.map(FileBrowserNode.init(entry:))
node.children = entries.map { FileBrowserNode(entry: $0) }
outline.reloadItem(node, reloadChildren: true)
} catch {
guard let self, let node, generation == request, !Task.isCancelled else { return }
Expand Down Expand Up @@ -303,6 +378,15 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
pathField.setAccessibilityLabel("파일 트리 폴더 경로")
pathField.target = self
pathField.action = #selector(goToPath)
searchField.font = ChopStyle.font
searchField.placeholderString = "파일·폴더 이름 검색"
searchField.setAccessibilityLabel("파일 트리 검색")
searchField.toolTip = "현재 폴더와 하위 폴더에서 이름으로 검색"
searchField.focusRingType = .none
searchField.sendsSearchStringImmediately = true
searchField.delegate = self
searchField.target = self
searchField.action = #selector(searchChanged)
let shortcuts = NSStackView()
shortcuts.orientation = .horizontal
shortcuts.alignment = .centerY
Expand Down Expand Up @@ -362,12 +446,13 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
listSection.layer?.cornerRadius = 8
listSection.layer?.masksToBounds = true
listSection.addSubview(listContent)
let content = NSStackView(views: [pathField, shortcuts, listSection])
let content = NSStackView(views: [pathField, shortcuts, searchField, listSection])
content.orientation = .vertical
content.alignment = .leading
content.spacing = 8
for view in [
header, headerRow, content, pathField, shortcuts, listSection, listContent, statusLabel,
header, headerRow, content, pathField, shortcuts, searchField, listSection, listContent,
statusLabel,
scrollView,
] {
view.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -390,6 +475,8 @@ final class FileBrowserView: NSView, NSOutlineViewDataSource, NSOutlineViewDeleg
pathField.widthAnchor.constraint(equalTo: content.widthAnchor),
pathField.heightAnchor.constraint(equalToConstant: 28),
shortcuts.widthAnchor.constraint(lessThanOrEqualTo: content.widthAnchor),
searchField.widthAnchor.constraint(equalTo: content.widthAnchor),
searchField.heightAnchor.constraint(equalToConstant: 28),
listSection.widthAnchor.constraint(equalTo: content.widthAnchor),
listContent.leadingAnchor.constraint(equalTo: listSection.leadingAnchor, constant: 8),
listContent.trailingAnchor.constraint(equalTo: listSection.trailingAnchor, constant: -8),
Expand Down
72 changes: 72 additions & 0 deletions Sources/ChopCore/FileTreeSearch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

public struct FileTreeSearchMatch: Equatable, Sendable {
public let entry: FileTreeEntry
/// Built while walking, so canonicalized /tmp or /var URLs do not lose their ancestry.
public let relativePath: String
}

public struct FileTreeSearchResult: Equatable, Sendable {
public let matches: [FileTreeSearchMatch]
public var entries: [FileTreeEntry] { matches.map(\.entry) }
public let isTruncated: Bool
public let skippedDirectories: Int
}

public enum FileTreeSearch {
public typealias Reader = @Sendable (URL) async throws -> [FileTreeEntry]

/// Searches visible names below a root, using the tree's package and symlink leaf rules.
public static func search(
at directory: URL, matching query: String, limit: Int = 500,
reader: Reader = { try FileTree.entries(at: $0) }
) async throws -> FileTreeSearchResult {
precondition(limit > 0)
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
var matches: [FileTreeSearchMatch] = []
var skipped = 0
func result(truncated: Bool = false) -> FileTreeSearchResult {
FileTreeSearchResult(
matches: Array(matches.prefix(limit)).sorted {
if $0.entry.isDirectory != $1.entry.isDirectory { return $0.entry.isDirectory }
return $0.relativePath.localizedStandardCompare($1.relativePath) == .orderedAscending
}, isTruncated: truncated, skippedDirectories: skipped)
}
try Task.checkCancellation()
guard !query.isEmpty else { return result() }
let root = directory.standardizedFileURL
var pending = [(url: root, path: "")]
var visited = Set<URL>()
while let (folder, parentPath) = pending.popLast() {
try Task.checkCancellation()
guard visited.insert(folder.standardizedFileURL).inserted else { continue }
let entries: [FileTreeEntry]
do {
entries = try await reader(folder)
} catch is CancellationError {
throw CancellationError()
} catch {
try Task.checkCancellation()
if folder == root { throw error }
skipped += 1
continue
}
var children: [(url: URL, path: String)] = []
for entry in entries {
try Task.checkCancellation()
let path =
parentPath.isEmpty
? entry.url.lastPathComponent : parentPath + "/" + entry.url.lastPathComponent
if entry.name.localizedStandardContains(query)
|| entry.url.lastPathComponent.localizedStandardContains(query)
{
matches.append(FileTreeSearchMatch(entry: entry, relativePath: path))
if matches.count > limit { return result(truncated: true) }
}
if entry.isDirectory { children.append((entry.url, path)) }
}
pending.append(contentsOf: children.reversed())
}
return result()
}
}
Loading
Loading