Add task view shortcuts and keyboard navigation in Files
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) } ?? [])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user