Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/contributor/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -1117,13 +1117,26 @@ between them, one owner per drawer — now reachable without a second device.
| The connection, and which server it points at | window |
| The client id, and so the layout it reads (§5) | the connection, held by whichever window points at it |
| The label other clients display (`Hello.label`) | window |
| The window title | window, and it is the connection's name |
| The webview's RPC, and the pushes routed to it | window |
| The window frame | window |
| The connection list and its pinned host keys | process |
| The client home: `known_hosts`, client settings, the window list, the connection-to-client-id map | process |
| The local server, its watchers, its vault, its PTYs | process |
| The menu bar | process, driven by the focused window |

**A window is titled by the connection it is on**, so a title bar says "This
Mac" or "v1" rather than "Ledge". Three windows all called after the app tell a
person nothing in the Window menu, in App Exposé, or in a tabbed window's tab
bar, and the one thing a second window exists for is being on a second machine.
The manager reports the name through its `onName` seam and `bun/index.ts` puts
it on the window: at boot, before the window is built, so no window is ever
titled for a machine it is not on; on a switch; and on a rename of the
connection being served, which is the one case `onSelect` cannot carry, since
the window went nowhere and the string on it is what changed. A window that
fell back is titled for the machine it landed on, not the one it wanted — the
indicator is what explains the difference.

**The list is shared and the selection is not.** `connections.json` holds the
list and the pins for the whole app, because a machine you have paired with is
a fact about this Mac rather than about one of its windows. Which connection a
Expand Down
2 changes: 2 additions & 0 deletions docs/user/18-notes-on-another-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ New Window in the File menu opens a second window, and each window is on its own

A new window opens on this Mac. Switch it wherever you like from inside it.

Each window is titled after the machine it is on, so the title bar reads "This Mac" or the name you gave the connection. That is the name in the Window menu too, and on a window's tab when macOS merges your windows into tabs.

Each window keeps its own tabs and panes, and the server remembers them: switch a window back to a machine you used before and its arrangement comes back. Ledge reopens every window you left open at the next launch, each on the machine it was pointed at.

Two windows on the same machine are the exception. Only one of them can be that machine's arrangement, so the second opens empty and does not overwrite the first.
Expand Down
79 changes: 79 additions & 0 deletions src/bun/connectionManager.fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,85 @@ describe("switching", () => {
});
});

// What the shell puts in the title bar (remote.md §8a). Every window said
// "Ledge" before this, which told a person with three of them open nothing at
// all: the one thing a second window is for is being on a second machine.
describe("naming the window", () => {
const named = async (deps: Parameters<typeof createConnectionManager>[0]) => {
const names: string[] = [];
const m = await createConnectionManager({ ...deps, onName: (name) => names.push(name) });
return { m, names };
};

// Reported before the manager returns, because the shell builds the window
// after that and a window is titled at birth.
test("a window is named as it boots", async () => {
await saveConnections([LAPTOP], LAPTOP.id);
const { names } = await named({ attach: fakeAttach().attach });
expect(names).toEqual(["Laptop"]);
});

// The title says where the window IS, not where it was asked to go: the
// indicator is what explains the difference.
test("a window that fell back is named for the machine it landed on", async () => {
await saveConnections([LAPTOP], LAPTOP.id);
const { names } = await named({ attach: fakeAttach(new Set([LAPTOP.id])).attach });
expect(names).toEqual(["This Mac"]);
});

test("switching renames the window", async () => {
await saveConnections([LAPTOP], LOCAL_ID);
const { m, names } = await named({ attach: fakeAttach().attach });
await m.requests.connectionSelect({ id: LAPTOP.id });
await m.requests.connectionSelect({ id: LOCAL_ID });
expect(names).toEqual(["This Mac", "Laptop", "This Mac"]);
});

test("a switch that did not happen renames nothing", async () => {
await saveConnections([LAPTOP], LOCAL_ID);
const { m, names } = await named({ attach: fakeAttach(new Set([LAPTOP.id])).attach });
await m.requests.connectionSelect({ id: LAPTOP.id });
await m.requests.connectionSelect({ id: "nope" });
expect(names).toEqual(["This Mac"]);
});

// The one case `onSelect` cannot carry: the window went nowhere, and the
// string on it is the one that changed.
test("renaming the connection being served renames the window", async () => {
await saveConnections([LAPTOP], LAPTOP.id);
const { m, names } = await named({ attach: fakeAttach().attach });
await m.requests.connectionUpdate({
id: LAPTOP.id,
name: "Studio",
destination: LAPTOP.destination,
port: PORT_UNSET,
keyPath: "",
auth: "key",
password: null,
hostKey: null,
});
expect(names).toEqual(["Laptop", "Studio"]);
});

// A window on this Mac while another connection is edited keeps its title:
// the edit is about a machine it is not looking at.
test("renaming a connection this window is not on renames nothing", async () => {
await saveConnections([LAPTOP], LOCAL_ID);
const { m, names } = await named({ attach: fakeAttach().attach });
await m.requests.connectionUpdate({
id: LAPTOP.id,
name: "Studio",
destination: LAPTOP.destination,
port: PORT_UNSET,
keyPath: "",
auth: "key",
password: null,
hostKey: null,
});
expect(names).toEqual(["This Mac"]);
});
});

describe("adding and removing", () => {
test("an added connection is listed, stored, and switchable", async () => {
const m = await createConnectionManager({ attach: fakeAttach().attach });
Expand Down
15 changes: 15 additions & 0 deletions src/bun/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export async function createConnectionManager(deps: {
* in the window list — that list is where the next launch reads it from, not
* `connections.json` (§8a). */
onSelect?(id: string): void;
/** Told what this window's connection is CALLED, whenever that answer
* changes: a switch, a boot that fell back, or a rename of the connection
* being served. The shell puts it in the window's title, which is the only
* label that says which machine a window is showing once there is more than
* one of them (§8a). Separate from `onSelect` because a rename moves no
* window anywhere and must not rewrite the window list. */
onName?(name: string): void;
now?: () => number;
}): Promise<ConnectionManager> {
// Declared before the store, which closes over it: a private store's "what is
Expand All @@ -106,6 +113,10 @@ export async function createConnectionManager(deps: {
active = LOCAL_CONNECTION;
}
selected = active.id;
// Before the window exists, which is the point: the shell holds the name and
// hands it to the window it is about to build, so no window is ever titled
// for a machine it is not on.
deps.onName?.(active.name);
if (active.id !== LOCAL_ID) await store.touch(active.id);

// Built once from the schema's own list, so a method added there is routed
Expand Down Expand Up @@ -155,6 +166,7 @@ export async function createConnectionManager(deps: {
error = "";
previous.shutdown();
deps.onSelect?.(next.id);
deps.onName?.(next.name);
if (next.id !== LOCAL_ID) await store.touch(next.id);
return { ok: true, error: "" };
},
Expand Down Expand Up @@ -208,6 +220,9 @@ export async function createConnectionManager(deps: {
} else if (fields.id === active.id) {
active = next;
}
// A rename of the connection this window is on: nothing about the wire
// changed, but the window's title named the old string a moment ago.
if (fields.id === active.id) deps.onName?.(active.name);
await store.write(next);
return { ok: true, error: "" };
},
Expand Down
27 changes: 26 additions & 1 deletion src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ interface Win {
connection: string;
/** The id this window is known by on that connection. */
client: string;
/** What the title bar says: the name of the connection this window is on.
* Kept here as well as on the window because the manager settles it before
* there is a window to put it on. */
title: string;
/** Where the window was last seen NOT fullscreen: a fullscreen frame is the
* screen's geometry rather than a choice, and restoring it would open a
* windowed app at exactly screen size. */
Expand Down Expand Up @@ -441,6 +445,23 @@ function workAreas(): Rect[] {
// coordinates look like one window.
const CASCADE = 28;

/**
* Title a window after the connection it is on (remote.md §8a).
*
* The app's name is not in it. Two windows both saying "Ledge" is what the
* title bar, the Window menu and the App Exposé grid all showed before this,
* and the one thing a person needs from them is which machine they are looking
* at — the app they can see. It follows the connection rather than the note in
* front of it because a window is a client: what changes underneath it is the
* machine, and the notes are named by their own tabs.
*/
function nameWindow(win: Win, name: string): void {
win.title = name;
// Null before the window is built, which is where the first report lands;
// `buildWindow` reads win.title back when it constructs one.
win.window?.setTitle(name);
}

function snapshot(): WindowState[] {
return windows.map((w) => ({ frame: w.frame, connection: w.connection || LOCAL_ID }));
}
Expand Down Expand Up @@ -513,6 +534,9 @@ async function buildWindow(want: string, frame?: Rect): Promise<void> {
manager: null,
connection: "",
client: "",
// Replaced by the manager's first report, which lands before the window
// below is built. It stands only if a window ever opens without one.
title: "Ledge",
frame: start,
reported: start,
menu: null,
Expand All @@ -530,6 +554,7 @@ async function buildWindow(want: string, frame?: Rect): Promise<void> {
// moment it changes rather than at quit: a switch that a crash swallowed
// would otherwise reopen the window on the machine it left.
onSelect: () => saveWindows(),
onName: (name) => nameWindow(win, name),
});
} catch (err) {
// Nothing to put a webview on. One window failing to open must not take
Expand All @@ -540,7 +565,7 @@ async function buildWindow(want: string, frame?: Rect): Promise<void> {
}

rpc = defineLedgeRPC(win.manager.requests);
const browser = new BrowserWindow({ title: "Ledge", url: await mainViewUrl(), rpc, frame: start });
const browser = new BrowserWindow({ title: win.title, url: await mainViewUrl(), rpc, frame: start });
win.window = browser;
win.id = browser.id;

Expand Down
Loading