Skip to content

Commit a02eb02

Browse files
authored
Make rename tab and rename project rebindable in Keymaps (#64)
1 parent 9915f65 commit a02eb02

5 files changed

Lines changed: 50 additions & 10 deletions

File tree

Macterm/App/AppCommand.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ enum AppCommand: String, CaseIterable, Identifiable {
146146
case .toggleCommandPalette: .toggleCommandPalette
147147
case .reloadGhosttyConfig: .reloadGhosttyConfig
148148
case .toggleQuickTerminal: .toggleQuickTerminal
149-
case .renameTab,
150-
.renameProject,
151-
.removeProject,
149+
case .renameTab: .renameTab
150+
case .renameProject: .renameProject
151+
case .removeProject,
152152
.replaceProjectPathWithCurrentDir,
153153
.applyLayout,
154154
.saveLayout: nil

Macterm/App/AppCommandActions.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ extension AppCommand {
4444
let tabID = tab.id
4545
return {
4646
ctx.appState.sidebarVisible = true
47-
ctx.appState.renamingTabID = tabID
47+
// Defer a tick so the sidebar (and its row's TextField) is in
48+
// the hierarchy before we ask it to begin editing — otherwise,
49+
// when the sidebar was collapsed, the field can't take first
50+
// responder. Applies to every caller (palette, menu, hotkey).
51+
DispatchQueue.main.async { ctx.appState.renamingTabID = tabID }
4852
}
4953
case .splitRight:
5054
guard let projectID else { return nil }
@@ -89,7 +93,8 @@ extension AppCommand {
8993
let projectID = current.id
9094
return {
9195
ctx.appState.sidebarVisible = true
92-
ctx.appState.renamingProjectID = projectID
96+
// See .renameTab: defer so the sidebar row exists first.
97+
DispatchQueue.main.async { ctx.appState.renamingProjectID = projectID }
9398
}
9499
case .removeProject:
95100
guard let projectID else { return nil }

Macterm/App/Hotkeys.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
3333
case toggleCommandPalette = "toggle_command_palette"
3434
case reloadGhosttyConfig = "reload_ghostty_config"
3535
case toggleQuickTerminal = "toggle_quick_terminal"
36+
case renameTab = "rename_tab"
37+
case renameProject = "rename_project"
3638

3739
var id: String { rawValue }
3840

@@ -69,6 +71,8 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
6971
case .toggleCommandPalette: "cmd+p"
7072
case .reloadGhosttyConfig: "cmd+shift+,"
7173
case .toggleQuickTerminal: "ctrl+`"
74+
case .renameTab: "cmd+r"
75+
case .renameProject: "none"
7276
}
7377
}
7478
}

Macterm/App/Responders.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,20 @@ final class MainAppResponder: KeyResponder {
247247
return .handled
248248
}
249249

250+
// Rename routes through AppCommand.action(in:) — the single source of
251+
// truth shared with the palette and menu bar — so the three paths can't
252+
// drift. The action defers begin-editing a tick (see AppCommandActions)
253+
// so the sidebar row's TextField exists before it takes first responder.
254+
for action in [HotkeyAction.renameTab, .renameProject] {
255+
guard HotkeyRegistry.matches(event, action: action),
256+
let command = AppCommand.allCases.first(where: { $0.hotkeyAction == action })
257+
else { continue }
258+
let ctx = AppCommandContext(appState: appState, projectStore: projectStore)
259+
guard let run = command.action(in: ctx) else { return .passThrough }
260+
run()
261+
return .handled
262+
}
263+
250264
// Cmd+1-9 tab selection. Must check after the configurable hotkeys
251265
// so user bindings take precedence over digits.
252266
if flags == .command {

MactermTests/Palette/CommandSourceTests.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ struct CommandSourceTests {
2525
CommandSource().emptyItems(context: ctx)?.first { $0.title == title }
2626
}
2727

28+
/// The rename actions defer setting `renaming…ID` to the next main-queue
29+
/// tick (so the sidebar row's TextField exists before it takes first
30+
/// responder — see `AppCommand.action(in:)`). Spin the run loop so that
31+
/// deferred work lands before asserting.
32+
private func flushMainQueue() async {
33+
// Enqueue behind any pending DispatchQueue.main.async work; main-queue
34+
// FIFO ordering guarantees the deferred set has run once this resumes.
35+
await withCheckedContinuation { continuation in
36+
DispatchQueue.main.async { continuation.resume() }
37+
}
38+
}
39+
2840
// MARK: - renameTab command availability
2941

3042
@Test
@@ -50,20 +62,21 @@ struct CommandSourceTests {
5062
}
5163

5264
@Test
53-
func renameTab_postPaletteAction_sets_sidebarVisible_and_renamingTabID() throws {
65+
func renameTab_postPaletteAction_sets_sidebarVisible_and_renamingTabID() async throws {
5466
let (ctx, state) = makeContext()
5567
let activeTabID = try #require(
5668
state.activeProjectID.flatMap { state.workspaces[$0]?.activeTabID }
5769
)
5870
let item = try #require(findItem(title: AppCommand.renameTab.title, in: ctx))
5971
item.action()
6072
state.postPaletteAction?()
73+
await flushMainQueue()
6174
#expect(state.sidebarVisible)
6275
#expect(state.renamingTabID == activeTabID)
6376
}
6477

6578
@Test
66-
func renameTab_postPaletteAction_targets_active_tab_at_time_of_action() throws {
79+
func renameTab_postPaletteAction_targets_active_tab_at_time_of_action() async throws {
6780
let (ctx, state) = makeContext()
6881
let projectID = try #require(state.activeProjectID)
6982
let ws = try #require(state.workspaces[projectID])
@@ -81,6 +94,7 @@ struct CommandSourceTests {
8194
ws.selectTab(firstTabID)
8295
item.action()
8396
state.postPaletteAction?()
97+
await flushMainQueue()
8498

8599
// renamingTabID should be the tab that was active when the item was built.
86100
#expect(state.renamingTabID == secondTab.id)
@@ -112,31 +126,34 @@ struct CommandSourceTests {
112126
}
113127

114128
@Test
115-
func renameProject_postPaletteAction_sets_sidebarVisible_and_renamingProjectID() throws {
129+
func renameProject_postPaletteAction_sets_sidebarVisible_and_renamingProjectID() async throws {
116130
let (ctx, state) = makeContext()
117131
let projectID = try #require(state.activeProjectID)
118132
let item = try #require(findItem(title: AppCommand.renameProject.title, in: ctx))
119133
item.action()
120134
state.postPaletteAction?()
135+
await flushMainQueue()
121136
#expect(state.sidebarVisible)
122137
#expect(state.renamingProjectID == projectID)
123138
}
124139

125140
@Test
126-
func renameProject_does_not_set_renamingTabID() throws {
141+
func renameProject_does_not_set_renamingTabID() async throws {
127142
let (ctx, state) = makeContext()
128143
let item = try #require(findItem(title: AppCommand.renameProject.title, in: ctx))
129144
item.action()
130145
state.postPaletteAction?()
146+
await flushMainQueue()
131147
#expect(state.renamingTabID == nil)
132148
}
133149

134150
@Test
135-
func renameTab_does_not_set_renamingProjectID() throws {
151+
func renameTab_does_not_set_renamingProjectID() async throws {
136152
let (ctx, state) = makeContext()
137153
let item = try #require(findItem(title: AppCommand.renameTab.title, in: ctx))
138154
item.action()
139155
state.postPaletteAction?()
156+
await flushMainQueue()
140157
#expect(state.renamingProjectID == nil)
141158
}
142159
}

0 commit comments

Comments
 (0)