Skip to content

Commit f619bf8

Browse files
committed
Add default directory action to session/window kebab menus
1 parent 9875092 commit f619bf8

7 files changed

Lines changed: 237 additions & 6 deletions

File tree

src/backend/server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,16 @@ export const createTmuxMobileServer = (
236236
case "swap_window":
237237
await deps.tmux.swapWindow(message.session, message.srcIndex, message.dstIndex);
238238
return;
239+
case "set_session_default_directory":
240+
await deps.tmux.setSessionDefaultDirectory(message.session, message.directory ?? null);
241+
return;
242+
case "set_window_default_directory":
243+
await deps.tmux.setWindowDefaultDirectory(
244+
message.session,
245+
message.windowIndex,
246+
message.directory ?? null
247+
);
248+
return;
239249
case "auth":
240250
return;
241251
default: {

src/backend/tmux/cli-executor.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const execFileAsync = promisify(execFile);
99
const SESSION_FMT = "#{session_name}\t#{session_attached}\t#{session_windows}";
1010
const WINDOW_FMT = "#{window_index}\t#{window_name}\t#{window_active}\t#{window_panes}";
1111
const PANE_FMT = "#{pane_index}\t#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_width}x#{pane_height}";
12+
const SESSION_DEFAULT_DIRECTORY_OPTION = "@tmux_mobile_session_default_directory";
13+
const WINDOW_DEFAULT_DIRECTORY_OPTION = "@tmux_mobile_window_default_directory";
1214

1315
interface TmuxCliExecutorOptions {
1416
socketName?: string;
@@ -77,6 +79,23 @@ export class TmuxCliExecutor implements TmuxGateway {
7779
}
7880
}
7981

82+
private async showOption(args: string[]): Promise<string | null> {
83+
try {
84+
const value = await this.runTmux(["show-options", "-qv", ...args]);
85+
return value === "" ? null : value;
86+
} catch {
87+
return null;
88+
}
89+
}
90+
91+
private async getSessionDefaultDirectory(session: string): Promise<string | null> {
92+
return this.showOption(["-t", session, SESSION_DEFAULT_DIRECTORY_OPTION]);
93+
}
94+
95+
private async getWindowDefaultDirectory(windowId: string): Promise<string | null> {
96+
return this.showOption(["-w", "-t", windowId, WINDOW_DEFAULT_DIRECTORY_OPTION]);
97+
}
98+
8099
public async listSessions() {
81100
const output = await this.runTmuxMaybeNoServer(["list-sessions", "-F", SESSION_FMT]);
82101
if (!output) {
@@ -120,7 +139,12 @@ export class TmuxCliExecutor implements TmuxGateway {
120139
}
121140

122141
public async newWindow(session: string): Promise<void> {
123-
await this.runTmux(["new-window", "-t", session]);
142+
const defaultDirectory = await this.getSessionDefaultDirectory(session);
143+
const args = ["new-window", "-t", session];
144+
if (defaultDirectory) {
145+
args.push("-c", defaultDirectory);
146+
}
147+
await this.runTmux(args);
124148
}
125149

126150
public async killWindow(session: string, windowIndex: number): Promise<void> {
@@ -132,7 +156,20 @@ export class TmuxCliExecutor implements TmuxGateway {
132156
}
133157

134158
public async splitWindow(paneId: string, orientation: "h" | "v"): Promise<void> {
135-
await this.runTmux(["split-window", `-${orientation}`, "-t", paneId]);
159+
const [windowId, sessionName] = await Promise.all([
160+
this.runTmux(["display-message", "-p", "-t", paneId, "#{window_id}"]),
161+
this.runTmux(["display-message", "-p", "-t", paneId, "#{session_name}"])
162+
]);
163+
const windowDirectory = await this.getWindowDefaultDirectory(windowId);
164+
const sessionDirectory = await this.getSessionDefaultDirectory(sessionName);
165+
const defaultDirectory = windowDirectory ?? sessionDirectory;
166+
167+
const args = ["split-window", `-${orientation}`];
168+
if (defaultDirectory) {
169+
args.push("-c", defaultDirectory);
170+
}
171+
args.push("-t", paneId);
172+
await this.runTmux(args);
136173
}
137174

138175
public async killPane(paneId: string): Promise<void> {
@@ -170,4 +207,56 @@ export class TmuxCliExecutor implements TmuxGateway {
170207
public async swapWindow(session: string, srcIndex: number, dstIndex: number): Promise<void> {
171208
await this.runTmux(["swap-window", "-s", `${session}:${srcIndex}`, "-t", `${session}:${dstIndex}`]);
172209
}
210+
211+
public async setSessionDefaultDirectory(
212+
session: string,
213+
directory: string | null
214+
): Promise<void> {
215+
if (!directory) {
216+
await this.runTmux([
217+
"set-option",
218+
"-u",
219+
"-t",
220+
session,
221+
SESSION_DEFAULT_DIRECTORY_OPTION
222+
]);
223+
return;
224+
}
225+
226+
await this.runTmux([
227+
"set-option",
228+
"-t",
229+
session,
230+
SESSION_DEFAULT_DIRECTORY_OPTION,
231+
directory
232+
]);
233+
}
234+
235+
public async setWindowDefaultDirectory(
236+
session: string,
237+
windowIndex: number,
238+
directory: string | null
239+
): Promise<void> {
240+
const target = `${session}:${windowIndex}`;
241+
if (!directory) {
242+
await this.runTmux([
243+
"set-option",
244+
"-u",
245+
"-w",
246+
"-t",
247+
target,
248+
WINDOW_DEFAULT_DIRECTORY_OPTION
249+
]);
250+
return;
251+
}
252+
253+
await this.runTmux([
254+
"set-option",
255+
"-w",
256+
"-t",
257+
target,
258+
WINDOW_DEFAULT_DIRECTORY_OPTION,
259+
directory
260+
]);
261+
}
173262
}

src/backend/tmux/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export interface TmuxGateway {
2626
respawnPane(paneId: string): Promise<void>;
2727
breakPane(paneId: string): Promise<void>;
2828
swapWindow(session: string, srcIndex: number, dstIndex: number): Promise<void>;
29+
setSessionDefaultDirectory(session: string, directory: string | null): Promise<void>;
30+
setWindowDefaultDirectory(
31+
session: string,
32+
windowIndex: number,
33+
directory: string | null
34+
): Promise<void>;
2935
}
3036

3137
export const buildSnapshot = async (

src/backend/types/protocol.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export type ControlClientMessage =
1616
| { type: "kill_session"; session: string }
1717
| { type: "respawn_pane"; paneId: string }
1818
| { type: "break_pane"; paneId: string }
19-
| { type: "swap_window"; session: string; srcIndex: number; dstIndex: number };
19+
| { type: "swap_window"; session: string; srcIndex: number; dstIndex: number }
20+
| { type: "set_session_default_directory"; session: string; directory?: string }
21+
| { type: "set_window_default_directory"; session: string; windowIndex: number; directory?: string };
2022

2123
export interface TmuxSessionSummary {
2224
name: string;

src/frontend/App.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,22 @@ export const App = () => {
657657
sendControl({ type: "rename_session", session: session.name, newName });
658658
}
659659
}}>Rename</button>
660+
<button onClick={() => {
661+
setOpenMenu(null);
662+
const directory = window.prompt(
663+
"Default directory for new panes in this session (leave blank to clear)",
664+
""
665+
);
666+
if (directory === null) {
667+
return;
668+
}
669+
const normalizedDirectory = directory.trim();
670+
sendControl({
671+
type: "set_session_default_directory",
672+
session: session.name,
673+
directory: normalizedDirectory || undefined
674+
});
675+
}}>Set Default Directory</button>
660676
<button onClick={() => {
661677
setOpenMenu(null);
662678
sendControl({ type: "new_window", session: session.name });
@@ -720,6 +736,23 @@ export const App = () => {
720736
sendControl({ type: "rename_window", session: activeSession.name, windowIndex: windowState.index, newName });
721737
}
722738
}}>Rename</button>
739+
<button onClick={() => {
740+
setOpenMenu(null);
741+
const directory = window.prompt(
742+
"Default directory for new panes in this window (leave blank to clear)",
743+
""
744+
);
745+
if (directory === null) {
746+
return;
747+
}
748+
const normalizedDirectory = directory.trim();
749+
sendControl({
750+
type: "set_window_default_directory",
751+
session: activeSession.name,
752+
windowIndex: windowState.index,
753+
directory: normalizedDirectory || undefined
754+
});
755+
}}>Set Default Directory</button>
723756
<button onClick={() => {
724757
setOpenMenu(null);
725758
sendControl({ type: "split_pane", paneId: windowState.panes[0]?.id ?? "", orientation: "h" });

tests/harness/fakeTmux.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const buildDefaultSession = (name: string): SessionNode => ({
5959
export class FakeTmuxGateway implements TmuxGateway {
6060
private sessions: SessionNode[] = [];
6161
private failSwitchClient = false;
62+
private readonly sessionDefaultDirectories = new Map<string, string>();
63+
private readonly windowDefaultDirectories = new Map<string, string>();
6264
public readonly calls: string[] = [];
6365

6466
public constructor(seedSessions: string[] = [], options: FakeTmuxOptions = {}) {
@@ -136,6 +138,7 @@ export class FakeTmuxGateway implements TmuxGateway {
136138
window.active = false;
137139
}
138140
const nextIndex = session.windows.at(-1)?.index ?? -1;
141+
const defaultDirectory = this.sessionDefaultDirectories.get(sessionName);
139142
session.windows.push({
140143
index: nextIndex + 1,
141144
name: `win-${nextIndex + 1}`,
@@ -144,7 +147,7 @@ export class FakeTmuxGateway implements TmuxGateway {
144147
{
145148
index: 0,
146149
id: `%${paneCounter++}`,
147-
command: "bash",
150+
command: defaultDirectory ? `bash@${defaultDirectory}` : "bash",
148151
active: true,
149152
width: 120,
150153
height: 40
@@ -171,15 +174,22 @@ export class FakeTmuxGateway implements TmuxGateway {
171174
}
172175

173176
public async splitWindow(paneId: string, orientation: "h" | "v"): Promise<void> {
174-
this.calls.push(`splitWindow:${paneId}:${orientation}`);
175177
const { session, window } = this.findByPane(paneId);
178+
const windowKey = `${session.name}:${window.index}`;
179+
const defaultDirectory =
180+
this.windowDefaultDirectories.get(windowKey) ?? this.sessionDefaultDirectories.get(session.name);
181+
this.calls.push(
182+
defaultDirectory
183+
? `splitWindow:${paneId}:${orientation}:${defaultDirectory}`
184+
: `splitWindow:${paneId}:${orientation}`
185+
);
176186
for (const pane of window.panes) {
177187
pane.active = false;
178188
}
179189
window.panes.push({
180190
index: window.panes.length,
181191
id: `%${paneCounter++}`,
182-
command: "bash",
192+
command: defaultDirectory ? `bash@${defaultDirectory}` : "bash",
183193
active: true,
184194
width: orientation === "h" ? 60 : 120,
185195
height: orientation === "v" ? 20 : 40
@@ -215,6 +225,36 @@ export class FakeTmuxGateway implements TmuxGateway {
215225
return `captured ${lines} lines for ${paneId}`;
216226
}
217227

228+
public async setSessionDefaultDirectory(
229+
session: string,
230+
directory: string | null
231+
): Promise<void> {
232+
this.calls.push(
233+
`setSessionDefaultDirectory:${session}:${directory ?? "<unset>"}`
234+
);
235+
if (!directory) {
236+
this.sessionDefaultDirectories.delete(session);
237+
return;
238+
}
239+
this.sessionDefaultDirectories.set(session, directory);
240+
}
241+
242+
public async setWindowDefaultDirectory(
243+
session: string,
244+
windowIndex: number,
245+
directory: string | null
246+
): Promise<void> {
247+
const key = `${session}:${windowIndex}`;
248+
this.calls.push(
249+
`setWindowDefaultDirectory:${session}:${windowIndex}:${directory ?? "<unset>"}`
250+
);
251+
if (!directory) {
252+
this.windowDefaultDirectories.delete(key);
253+
return;
254+
}
255+
this.windowDefaultDirectories.set(key, directory);
256+
}
257+
218258
public setFailSwitchClient(value: boolean): void {
219259
this.failSwitchClient = value;
220260
}

tests/integration/server.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,57 @@ describe("tmux mobile server", () => {
182182
control.close();
183183
});
184184

185+
test("applies session and window default directories for new panes", async () => {
186+
await runningServer.stop();
187+
await startWithSessions(["main"]);
188+
189+
const control = await openSocket(`${baseWsUrl}/ws/control`);
190+
control.send(JSON.stringify({ type: "auth", token: "test-token" }));
191+
192+
await waitForMessage(control, (msg: { type: string }) => msg.type === "attached");
193+
const snapshot = await buildSnapshot(tmux);
194+
const paneId = snapshot.sessions[0].windowStates[0].panes[0].id;
195+
196+
control.send(
197+
JSON.stringify({
198+
type: "set_session_default_directory",
199+
session: "main",
200+
directory: "/tmp/session-default"
201+
})
202+
);
203+
control.send(JSON.stringify({ type: "split_pane", paneId, orientation: "h" }));
204+
205+
control.send(
206+
JSON.stringify({
207+
type: "set_window_default_directory",
208+
session: "main",
209+
windowIndex: 0,
210+
directory: "/tmp/window-default"
211+
})
212+
);
213+
control.send(JSON.stringify({ type: "split_pane", paneId, orientation: "v" }));
214+
215+
control.send(
216+
JSON.stringify({
217+
type: "set_window_default_directory",
218+
session: "main",
219+
windowIndex: 0
220+
})
221+
);
222+
control.send(JSON.stringify({ type: "split_pane", paneId, orientation: "h" }));
223+
224+
await new Promise((resolve) => setTimeout(resolve, 20));
225+
226+
expect(tmux.calls).toContain("setSessionDefaultDirectory:main:/tmp/session-default");
227+
expect(tmux.calls).toContain("setWindowDefaultDirectory:main:0:/tmp/window-default");
228+
expect(tmux.calls).toContain("setWindowDefaultDirectory:main:0:<unset>");
229+
expect(tmux.calls).toContain(`splitWindow:${paneId}:h:/tmp/session-default`);
230+
expect(tmux.calls).toContain(`splitWindow:${paneId}:v:/tmp/window-default`);
231+
expect(tmux.calls.filter((call) => call === `splitWindow:${paneId}:h:/tmp/session-default`)).toHaveLength(2);
232+
233+
control.close();
234+
});
235+
185236
test("stop is idempotent when called repeatedly", async () => {
186237
await runningServer.stop();
187238
await runningServer.stop();

0 commit comments

Comments
 (0)