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:
@@ -60,9 +60,12 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
|
||||
# when Quickshell is down -- which is exactly when somebody is reaching for the
|
||||
# launcher to look up what went wrong.
|
||||
#
|
||||
# The project commands are the same shape: they record or restore a window
|
||||
# layout, which is panama-project's job and involves the shell not at all.
|
||||
#
|
||||
# They are still commands, so everything else below applies to them: a title,
|
||||
# a description, search vocabulary, the Panama icon, and closing quietly.
|
||||
declare -a standalone=(search-web)
|
||||
declare -a standalone=(search-web save-project open-project)
|
||||
|
||||
# Generated commands must match their source. A stale command dispatches to a
|
||||
# page that has been renamed or removed, and the launcher reports nothing wrong.
|
||||
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Project sessions: the windows you open together, opened together.
|
||||
#
|
||||
# Three things here are easy to get wrong and were, each caught by running it
|
||||
# rather than reading it:
|
||||
#
|
||||
# * A terminal's directory is not the terminal's own working directory. That
|
||||
# is where the terminal was launched from; the shell inside it is what has
|
||||
# been cd'd. Reading the wrong one appeared correct for exactly as long as
|
||||
# the test terminals happened to have been started from the right place.
|
||||
# * gtk-launch cannot place a window. It activates over D-Bus, so the process
|
||||
# Hyprland started exits and a [workspace N silent] rule has no child to
|
||||
# apply to -- the window lands wherever you were looking.
|
||||
# * DBusActivatable applications do the same thing even when launched
|
||||
# directly, so the window has to be found and moved afterwards.
|
||||
#
|
||||
# The save/open round trip is exercised for real, against a fake HOME and a
|
||||
# stubbed hyprctl, so no window is opened on the machine running the tests.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-project"
|
||||
desktop_page="$repo_dir/config/dot/quickshell/modules/settings/DesktopPage.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Projects.qml"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -x "$helper" ]] || { printf 'projects contract: helper is missing or not executable\n' >&2; exit 1; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin" "$work/home"
|
||||
|
||||
# A compositor that reports two windows on two workspaces and accepts every
|
||||
# dispatch, recording what it was asked to do.
|
||||
cat >"$work/bin/hyprctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "$1" == "-j" && "$2" == "clients" ]]; then
|
||||
cat "$HYPRCTL_CLIENTS"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "eval" ]]; then
|
||||
printf '%s\n' "$2" >>"$HYPRCTL_LOG"
|
||||
echo ok
|
||||
exit 0
|
||||
fi
|
||||
echo "{}"
|
||||
STUB
|
||||
chmod +x "$work/bin/hyprctl"
|
||||
|
||||
cat >"$work/clients.json" <<'JSON'
|
||||
[
|
||||
{"address":"0x1","class":"kitty","workspace":{"name":"2"},"floating":false,"pid":1},
|
||||
{"address":"0x2","class":"org.gnome.Nautilus","workspace":{"name":"3"},"floating":false,"pid":2},
|
||||
{"address":"0x3","class":"bitwarden","workspace":{"name":"special:scratch"},"floating":true,"pid":3}
|
||||
]
|
||||
JSON
|
||||
|
||||
export HYPRCTL_CLIENTS="$work/clients.json"
|
||||
export HYPRCTL_LOG="$work/dispatch.log"
|
||||
: >"$HYPRCTL_LOG"
|
||||
|
||||
run() { PATH="$work/bin:$PATH" HOME="$work/home" XDG_DATA_HOME="$work/home/.local/share" "$helper" "$@"; }
|
||||
|
||||
# ── Saving ───────────────────────────────────────────────────────────────────
|
||||
|
||||
saved="$(run save demo)"
|
||||
[[ "$(jq -r '.ok' <<<"$saved")" == "true" ]] || note "saving a layout failed: $saved"
|
||||
|
||||
# Two workspaces, not three: the scratchpad is somewhere things are put aside,
|
||||
# not part of a layout, and a floating window is not a tiled arrangement.
|
||||
[[ "$(jq -r '.workspaces' <<<"$saved")" == "2" ]] \
|
||||
|| note "a layout with 2 tiled workspaces and a scratchpad saved $(jq -r '.workspaces' <<<"$saved") workspaces"
|
||||
[[ "$(jq -r '.windows' <<<"$saved")" == "2" ]] \
|
||||
|| note 'the scratchpad or a floating window was recorded as part of the layout'
|
||||
|
||||
stored="$work/home/.local/share/panama/projects/demo.json"
|
||||
[[ -f "$stored" ]] || note 'saving produced no project file'
|
||||
|
||||
if [[ -f "$stored" ]]; then
|
||||
# Positions, not numbers. A project saved from 2 and 3 must not insist on 2
|
||||
# and 3, or opening it lands on top of whatever is there now.
|
||||
jq -e '.workspaces | type == "array"' "$stored" >/dev/null 2>&1 \
|
||||
|| note 'workspaces are not stored as an ordered list of positions'
|
||||
jq -e '[.workspaces[].windows[] | has("index")] | any' "$stored" >/dev/null 2>&1 \
|
||||
&& note 'a workspace number was recorded, so the project would demand specific workspaces'
|
||||
|
||||
# gtk-launch cannot place a window; the Exec line can.
|
||||
jq -r '.workspaces[].windows[].exec // ""' "$stored" | grep -q 'gtk-launch' \
|
||||
&& note 'projects launch through gtk-launch, which activates over D-Bus and cannot be placed on a workspace'
|
||||
fi
|
||||
|
||||
# ── Opening ──────────────────────────────────────────────────────────────────
|
||||
|
||||
: >"$HYPRCTL_LOG"
|
||||
opened="$(run open demo)"
|
||||
[[ "$(jq -r '.ok' <<<"$opened")" == "true" ]] || note "opening a project failed: $opened"
|
||||
|
||||
# Free workspaces. 2 and 3 are occupied by the stub's own windows, so a project
|
||||
# saved from them must be laid out somewhere else entirely.
|
||||
targets="$(jq -r '.workspaces | join(",")' <<<"$opened")"
|
||||
[[ "$targets" == "1,4" ]] \
|
||||
|| note "a project opened onto [$targets] when 2 and 3 were occupied; it should claim free workspaces"
|
||||
|
||||
grep -q 'workspace 1 silent' "$HYPRCTL_LOG" \
|
||||
|| note 'windows are not launched with a workspace rule'
|
||||
grep -q 'exec_cmd' "$HYPRCTL_LOG" \
|
||||
|| note 'the Lua dispatch form is not used, and the plain hyprctl dispatch form is a syntax error on this config'
|
||||
|
||||
# ── Terminal directories ─────────────────────────────────────────────────────
|
||||
|
||||
# Exercised against a real process tree rather than grepped for. Checking that
|
||||
# the source mentions a shell name proves only that somebody wrote the word: an
|
||||
# earlier version of this contract passed against a helper that had been changed
|
||||
# back to reading the wrong process, because the constant was still there.
|
||||
#
|
||||
# The shape below is the shape that matters -- a parent sitting in one directory
|
||||
# with a shell child in another, which is exactly a terminal you have cd'd
|
||||
# inside. The answer must be the child's directory.
|
||||
mkdir -p "$work/parent" "$work/child"
|
||||
# exec, so the subshell is replaced by python and $! is python's own pid rather
|
||||
# than a shell that has already gone.
|
||||
( cd "$work/parent" && exec python3 -c "
|
||||
import subprocess, time
|
||||
subprocess.Popen(['bash', '-c', 'sleep 30; :'], cwd='$work/child')
|
||||
time.sleep(30)
|
||||
" ) >/dev/null 2>&1 &
|
||||
parent_pid=$!
|
||||
sleep 2
|
||||
if [[ -n "$parent_pid" ]]; then
|
||||
answer="$(PATH="$work/bin:$PATH" HOME="$work/home" python3 - "$helper" "$parent_pid" <<'PROBE'
|
||||
import importlib.machinery, importlib.util, sys
|
||||
spec = importlib.util.spec_from_loader("pp",
|
||||
importlib.machinery.SourceFileLoader("pp", sys.argv[1]))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
print(module.working_directory(int(sys.argv[2])) or "")
|
||||
PROBE
|
||||
)"
|
||||
[[ "$answer" == "$work/child" ]] \
|
||||
|| note "a terminal's directory is read as '$answer' rather than the shell's '$work/child' -- the terminal own working directory is where it was launched from, not where you have changed to"
|
||||
kill "$parent_pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# -d, not --directory with a space, which kitty accepts and silently ignores.
|
||||
grep -qE 'TERMINAL_DIRECTORY_FLAG = "-d"' "$helper" \
|
||||
|| note 'the terminal directory flag is not -d; the space-separated long form is ignored without an error'
|
||||
|
||||
# ── Placement has to be verified, not assumed ────────────────────────────────
|
||||
|
||||
grep -q 'hl.dsp.window.move' "$helper" \
|
||||
|| note 'a window that lands on the wrong workspace is never moved, so DBusActivatable applications stay where they appeared'
|
||||
|
||||
# ── The page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
grep -q 'Projects.projects' "$desktop_page" \
|
||||
|| note 'the Desktop page does not list saved projects'
|
||||
grep -q 'confirmingProject' "$desktop_page" \
|
||||
|| note 'a project can be deleted without confirming'
|
||||
grep -q '"list"' "$service" \
|
||||
|| note 'the settings service never reads what is saved'
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'projects contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'projects contract: PASS\n'
|
||||
Reference in New Issue
Block a user