Add task view shortcuts and keyboard navigation in Files

This commit is contained in:
Ian Gulliver
2026-10-23 22:05:27 -07:00
parent af0cd9b36c
commit 2a9971316f
4 changed files with 136 additions and 29 deletions
+6
View File
@@ -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()
+97 -28
View File
@@ -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")
+2
View File
@@ -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) } ?? [])
+31 -1
View File
@@ -3,10 +3,27 @@ import SwiftUI
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
let goKeys: Set<String> = ["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<String> = 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"),