Open the windows you open together, together

Arrange the desktop, run "Save Layout as Project" from the launcher, name it.
"Open Project" lays it out again on free workspaces, so it never lands on top of
what you are already doing. Saved layouts are listed on the Desktop settings
page, which is where they are removed.

Recorded rather than written by hand, and a terminal's directory is most of why
it is worth having: without it a project opens three terminals in your home
folder and you change directory three times. This machine had two terminals in
the same project when it was written, and reopening there is the difference
between a layout and a working desktop.

Four things had to be found by running it, none of which reading would have
shown.

A terminal's directory is not the terminal's working directory -- that is where
it was launched from. The shell inside it is what has been cd'd. Reading the
wrong one looked correct for exactly as long as the terminals under test had
been started from the right place, which they had.

gtk-launch cannot place a window. It activates over D-Bus, so the process
Hyprland started exits and a [workspace N silent] rule has nothing left to apply
to; Nautilus landed on whatever workspace was in front of you. The Exec line
from the desktop entry is run directly instead. But DBusActivatable
applications do the same thing regardless, so the window is found afterwards and
moved by address -- which also means a window that never appeared is reported
rather than assumed.

/proc/PID/task/PID/children is a file of pids, not a directory. Listing it as
one always raised, so the fast path was never once taken and everything went
through pgrep.

And kitty's --directory needs an equals sign or the short -d; the
space-separated long form is accepted and silently ignored.

The contract exercises a save and open round trip against a stubbed compositor,
and reads a terminal's directory out of a real process tree rather than grepping
the source for a shell name -- an earlier version passed against a helper that
had been changed back, because the constant was still there.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
This commit is contained in:
Gabriel Brown
2026-08-21 13:45:16 -04:00
parent 538c0a887c
commit 185d7edaa5
8 changed files with 810 additions and 2 deletions
@@ -0,0 +1,95 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Saved window layouts: what is on disk, and getting rid of the ones that are
// not worth keeping.
//
// Saving deliberately does not live here. A layout is worth recording at the
// moment you have it arranged, and that moment is not one to interrupt by going
// to find a settings page -- so it is a launcher command, and this is where
// they are reviewed and pruned afterwards.
//
// Opening is offered too, but as a convenience rather than the main route: from
// the launcher it is two keystrokes and never leaves the keyboard.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helper: Quickshell.shellDir + "/scripts/panama-project"
property var projects: []
property bool busy: false
property string lastError: ""
Process {
id: listRun
command: [root.helper, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const answer = JSON.parse(this.text);
root.projects = Array.isArray(answer.projects) ? answer.projects : [];
} catch (error) {
root.lastError = "Could not read the saved projects.";
}
}
}
onExited: root.busy = false
}
Process {
id: actionRun
stdout: StdioCollector {
onStreamFinished: {
try {
const answer = JSON.parse(this.text);
if (answer.ok !== true) {
// The one worth saying out loud: a project needs as many
// free workspaces as it was saved with, and being told
// "nothing happened" would be useless.
root.lastError = answer.error === "not-enough-free-workspaces"
? `Needs ${answer.needed} free workspaces; ${answer.available} are free.`
: "That did not work.";
}
} catch (error) {
root.lastError = "That did not work.";
}
}
}
onExited: {
root.busy = false;
root.refresh();
}
}
function refresh(): void {
if (!listRun.running)
listRun.running = true;
}
function open(name: string): void {
if (actionRun.running)
return;
root.lastError = "";
root.busy = true;
actionRun.command = [root.helper, "open", name];
actionRun.running = true;
}
function remove(name: string): void {
if (actionRun.running)
return;
root.lastError = "";
root.busy = true;
actionRun.command = [root.helper, "delete", name];
actionRun.running = true;
}
Component.onCompleted: root.refresh()
}