From 2a9971316f3f7797138707c4207fc73429108eb3 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 22:05:27 -0700 Subject: [PATCH] Add task view shortcuts and keyboard navigation in Files --- mac/Sources/Byre/ByreApp.swift | 6 ++ mac/Sources/Byre/Files.swift | 125 +++++++++++++++++++++++++------- mac/Sources/Byre/Panes.swift | 2 + mac/Sources/Byre/Switcher.swift | 32 +++++++- 4 files changed, 136 insertions(+), 29 deletions(-) diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index e2e72ff..6e88b4e 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -112,6 +112,12 @@ struct ByreCommands: Commands { goButton("Lead", "l") goButton("Merge", "s") goButton("Filter TODO Items", "f") + Divider() + ForEach(taskViewKeys, id: \.key) { item in + goButton(item.tab.label, item.key) + .disabled(focus?.store?.selectedTaskId == nil || focus?.store?.primaryTab == .workflow) + } + Divider() serverButton("Previous Server", "[", -1) serverButton("Next Server", "]", 1) Divider() diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index fa2d23e..79af302 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -100,7 +100,45 @@ private final class FilesModel { private(set) var error = "" private(set) var generation = 0 private(set) var loads = 0 + var cursor: String? var saving: DownloadedFile? + + var entries: [DirEntry] { + listing?.entries.filter { $0._type != .other } ?? [] + } + + func move(_ step: Int) { + guard !entries.isEmpty else { return } + let index = entries.firstIndex { $0.name == cursor } ?? 0 + cursor = entries[max(0, min(index + step, entries.count - 1))].name + } + + func open(_ entry: DirEntry) { + guard let listing else { return } + cursor = entry.name + let path = join(listing.path, entry.name) + if entry._type == .dir { + cursor = nil + go(FilePlace(dir: path)) + } else if entry._type == .file && isPage(entry.name), let root { + store.openPage(.file(root, path: path)) + } else if entry._type == .file { + go(FilePlace(dir: listing.path, file: path)) + } + } + + func up() -> Bool { + let p = place + if p.file != nil { + go(FilePlace(dir: p.dir)) + return true + } + let dir = listing?.path ?? p.dir + guard !dir.isEmpty && dir != "/" else { return false } + cursor = (dir as NSString).lastPathComponent + go(FilePlace(dir: parent(dir))) + return true + } var downloading = false var saveError = "" @ObservationIgnored private var target = 0 @@ -176,6 +214,9 @@ private final class FilesModel { } if started == target { listing = fresh + if !fresh.entries.contains(where: { $0.name == cursor && $0._type != .other }) { + cursor = fresh.entries.first(where: { $0._type != .other })?.name + } content = nil } } @@ -225,6 +266,7 @@ struct FilesView: View { let root: FileRoot? @Environment(Store.self) private var store @ViewState private var model: FilesModel? + @FocusState private var focused: Bool var body: some View { VStack(spacing: 0) { @@ -241,6 +283,26 @@ struct FilesView: View { } } .frame(maxHeight: .infinity, alignment: .top) + #if os(macOS) + .focusable() + .focusEffectDisabled() + .focused($focused) + .onAppear { focused = true } + .onKeyPress(keys: [.upArrow, .downArrow, .leftArrow, .rightArrow, .return]) { press in + guard let model, press.modifiers.isEmpty else { return .ignored } + if press.key == .leftArrow { + return model.up() ? .handled : .ignored + } + guard model.listing != nil else { return .ignored } + if press.key == .upArrow || press.key == .downArrow { + model.move(press.key == .downArrow ? 1 : -1) + return .handled + } + guard let entry = model.entries.first(where: { $0.name == model.cursor }) else { return .ignored } + model.open(entry) + return .handled + } + #endif .task(id: FilesKey(root: root, place: store.filePlace(root))) { let m = model ?? FilesModel(store) model = m @@ -384,39 +446,40 @@ private struct FilesBody: View { .id(model.generation) } } else if let l = model.listing { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if !l.path.isEmpty && l.path != "/" { - EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false, save: nil) { - model.go(FilePlace(dir: parent(l.path))) - } - } - ForEach(l.entries, id: \.name) { e in - let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ") - EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink, save: e._type == .file ? { model.save(join(l.path, e.name)) } : nil) { - if e._type == .dir { - model.go(FilePlace(dir: join(l.path, e.name))) - } else if e._type == .file && isPage(e.name), let root = model.root { - store.openPage(.file(root, path: join(l.path, e.name))) - } else if e._type == .file { - model.go(FilePlace(dir: l.path, file: join(l.path, e.name))) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !l.path.isEmpty && l.path != "/" { + EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false, save: nil) { + _ = model.up() } } + ForEach(l.entries, id: \.name) { e in + let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ") + EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink, selected: model.cursor == e.name, save: e._type == .file ? { model.save(join(l.path, e.name)) } : nil) { + model.open(e) + } + .id(e.name) + } + if l.entries.isEmpty { + Muted(text: "Empty directory.") + } + if l.truncated { + Text("Only the first \(l.entries.count) entries are shown.") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 8) + } } - if l.entries.isEmpty { - Muted(text: "Empty directory.") - } - if l.truncated { - Text("Only the first \(l.entries.count) entries are shown.") - .foregroundStyle(Palette.muted) - .padding(.horizontal, 8) + .padding(.horizontal, 8) + .padding(.top, 6) + .padding(.bottom, 16) + } + .onChange(of: model.cursor, initial: true) { + if let selected = model.cursor { + proxy.scrollTo(selected) } } - .padding(.horizontal, 8) - .padding(.top, 6) - .padding(.bottom, 16) } - .id(model.generation) } else { EmptyText(text: "Loading…") } @@ -429,6 +492,7 @@ private struct EntryRow: View { let ignored: Bool let meta: String let symlink: Bool + var selected = false let save: (() -> Void)? let action: () -> Void @ViewState private var hover = false @@ -451,7 +515,12 @@ private struct EntryRow: View { } .padding(.horizontal, 8) .padding(.vertical, 2) - .background(hover && kind != .other ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4)) + .background((hover || selected) && kind != .other ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4)) + .overlay(alignment: .leading) { + if selected { + Rectangle().fill(Palette.accent).frame(width: 2) + } + } .contentShape(Rectangle()) if kind == .other { row.help("Can't read the entry, or it isn't a regular file or directory") diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 8efc5dd..4ab966d 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -261,6 +261,8 @@ struct TaskTabBody: View { case .page: if let shown = store.shownPagesHere() { PageTabs(shown: shown) + } else { + EmptyText(text: "No pages open.") } case .captures: CapturesView(captures: store.selectedTaskId.map { store.captures($0) } ?? []) diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index de0a275..2d45b3f 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -3,10 +3,27 @@ import SwiftUI typealias WorkflowSummary = Components.Schemas.WorkflowSummary -let goKeys: Set = ["k", "n", "t", "p", "l", "s", "f", "/"] +let taskViewKeys: [(key: String, tab: TaskTab)] = [ + ("1", .transcript), ("2", .changes), ("3", .files), ("4", .page), ("5", .captures), ("6", .terminal), +] + +let goKeys: Set = Set(["k", "n", "t", "p", "l", "s", "f", "/"] + taskViewKeys.map(\.key)) @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { + if let tab = taskViewKeys.first(where: { $0.key == key })?.tab { + guard let taskId = store.selectedTaskId, store.findTask(taskId)?.workflow == nil else { return } + overlays.switcher?.close() + overlays.taskList = false + overlays.listKeys = false + if tab == .transcript { + store.openTab(.transcript) + store.composerFocus = taskId + } else { + store.sheet = Sheet(owner: .task(taskId), view: .tab(tab)) + } + return + } switch key { case "k": overlays.openSwitcher(store) @@ -91,6 +108,19 @@ private let shortcuts: [(String, [(String, String)])] = [ ("⌘/", "Show these shortcuts"), ("Esc", "Close the switcher, a dialog, or a view opened over a pane"), ]), + ("Task views", [ + ("⌘1", "Task transcript and composer"), + ("⌘2", "Changes"), + ("⌘3", "Files"), + ("⌘4", "Page"), + ("⌘5", "Captures"), + ("⌘6", "Terminal"), + ]), + ("Files", [ + ("↑ ↓", "Select an entry"), + ("Enter / →", "Open the selected entry"), + ("←", "Return to the containing directory"), + ]), ("Task switcher", [ ("Tab", "Switch between the text and the list"), ("↑ ↓", "Move the highlight, in the list"),