Omahub
← All plugins
W

Project Presets

by Weaklund

Snapshot a project workspace and bring it back with one click — apps on their workspaces, terminals in the directories they were in and running what they ran, Docker Compose stacks and browser pages.

Security review

No obvious issues detected

Deterministic scan — not a security guarantee

None
Risk level
None
Analyzed commit
35cf161
Scanned
1 month ago

No potentially dangerous behavior detected in the analyzed commit.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

None
AI risk level
None
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
35cf161
Reviewed
1 month ago

Independent review matches the deterministic scan: no obfuscated code, hidden persistence, credential theft, destructive install-time behavior, or network activity was found. The plugin records local window state and launches user-defined preset commands through Hyprland, which is its documented purpose; preset files should be treated as trusted code, but that is expected functionality rather than a vulnerability.

How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/monswiklund/omarchy-workspace-presets --enable
Productivity #bar #workspaces

Project Presets

A bar widget for Omarchy Quattro. Click it, pick a project, and every app in that preset opens on the workspace you assigned it — without the screen following along while they start.

It was called Workspace Presets until it was pointed out that a workspace in Hyprland is one numbered desktop, while a preset here spans several — and that the panel's own buttons had been saying project all along. The plugin id, the IPC name and the config file keep the old word on purpose: they are what an install and a keybinding are addressed by, and renaming them would break both for the sake of a word nobody types.

Requirements

Needed for What
Everything Omarchy Quattro — the shell plugin system does not exist before it
Snapshots hyprctl and jq, both already on an Omarchy install
Docker rows docker compose, only if you tick a stack; the picker is empty without it
Notifications omarchy-notification-send, and it degrades to silence without it

No other dependencies, nothing is downloaded at runtime, and nothing runs with sudo. The plugin writes exactly one file of its own, ~/.config/omarchy/workspace-presets.json, and never edits anything else you own — enabling and placing the widget is Omarchy's own shell.json handling, not this plugin's.

Terminal support is a small table: ghostty, foot and alacritty are verified. An unrecognised terminal still works, it just records without a directory. See Other terminals and browsers.

Install

omarchy plugin add https://github.com/monswiklund/omarchy-workspace-presets.git --enable

It lands on the left of the bar. Somewhere else:

omarchy plugin enable io.github.monswiklund.workspace-presets --section right

The bar icon and "skip apps that are already running" are widget settings, edited where every other widget's are.

Installed by hand instead:

omarchy-shell shell rescanPlugins
omarchy plugin enable io.github.monswiklund.workspace-presets

Managing presets

A row is the project and a chevron. Clicking the row launches it; the chevron opens everything you can do to it, with a readable label instead of a glyph to guess at — six icons on a row made a toolbar out of a list.

▣  Service System              ▶   ⌄
   · 󰖯 3   󰡨 1   󰖟 2   󰍹 1
   Contents                       ›
   Docker                        2  ›
   Pages                         2  ›
   Update from this workspace
   Close project
   Delete

Contents, Docker, Pages and Icon swap the contents of the same slot rather than opening a level below it — three levels deep in a bar popup is nobody's idea of navigable.

Action What it does
Launch The ▶ button. Clicking the row opens it instead — starting a project
opens windows and brings containers up, which is too much for a click
that only meant to look.
Contents Everything the preset holds, in the order it launches
Docker Tick the Compose projects this preset should bring up
Pages Paste a URL and press Enter; click a page to remove it
Icon Click the preset's own icon; a grid opens with the current one marked
Order Drag a row; the drop commits it
Rename Double-click the name
Update Replaces the window rows with the current workspace, keeps the rest
Close Closes the windows and brings the stacks down
Delete Removes the preset, not the windows

From a keybinding

A panel you have to open first cannot go on Super+1, so presets are also addressable by name:

omarchy-shell workspace-presets launch "Service System"
omarchy-shell workspace-presets list     # the names, one per line
omarchy-shell workspace-presets toggle   # the panel

launch answers ok or unknown, matching case-insensitively on the trimmed name. In ~/.config/hypr/bindings.lua:

o.bind("SUPER + code:10", "Service System",
  hl.dsp.exec_cmd("omarchy-shell workspace-presets launch 'Service System'"))

Reordering is the one thing the keyboard cannot do — it is a drag, and the menu rows that used to duplicate it are gone.

Keyboard in the panel: ↑/↓ walk the list, → walks the row's controls, Enter runs what is focused, ← and Esc back out one level at a time — a submode returns to the menu, the menu closes the row, and only then does Esc reach the panel. An armed confirmation is always one Esc from being called off.

Update, Close and Delete arm first: the row's own label becomes "Click again to confirm" and says what will happen — for Close, how many windows and stacks it found.

Rename, delete and every other edit rewrite the file in place and keep every key this plugin does not model, so a preset carrying your own fields survives. The panel adopts what it wrote in the same breath rather than waiting to be told — a FileView raises no change for its own write, and relying on that once left the list showing a preset that was already gone from disk.

Seeing what a preset holds

The summary counts; Contents names. Until it existed the only way to see a preset's windows was to open the JSON.

‹  Contents
   󰡨  Dev/api
   󰖯  docker compose logs -f       Dev/api    󰍹 2
   󰖯  claude --continue            Dev/web    󰍹 1
   󰖯  chromium                                󰍹 1
   󰖟  jira.example.com/browse/X

A terminal is named by what it runs rather than by itself — claude --continue is the window, ghostty is the box it came in — and detailed by where it runs. A terminal running nothing is named by itself. Home is said as ~ rather than spelled out.

What the list tells you

The summary counts each kind apart as glyphs — 󰖯 3 󰡨 1 󰖟 2 󰍹 1 for three windows, one Docker stack, two pages and workspace 1. A row of counts reads at a glance where a sentence has to be read, and "3 apps" on a preset holding two stacks and two pages was true and useless. Two numbers appear only when something would actually be left out: 󰖯 2/3.

Every glyph here was rendered in the bar's font and checked by eye before it went in, the same as the icon set.

A leading · and a filled row mark the project you are standing in: every window the preset places is on the workspace it places it on. Matching on the window class alone would mark every preset holding a terminal, because a terminal is always running somewhere — a light that is always on is not a light. A preset made only of commands it cannot recognise never claims to be up.

The list scrolls once it outgrows the panel. It has to — the panel clamps its height rather than growing, so a plain column would draw the last presets nowhere and leave them reachable by nothing. The actions below stay put while the projects scroll under them.

Presets

Presets live in ~/.config/omarchy/workspace-presets.json. Saving the file updates the panel immediately — no restart, no rescan.

{
  "version": 1,
  "presets": [
    {
      "name": "Sportson web",
      "icon": "󰅩",
      "focus": 1,
      "apps": [
        { "cmd": "ghostty --working-directory=~/code/web", "workspace": 1, "class": "com.mitchellh.ghostty" },
        { "cmd": "cursor ~/code/web",                      "workspace": 2, "class": "Cursor" },
        { "cmd": "chromium --new-window localhost:3000",   "workspace": 3, "class": "chromium" }
      ]
    }
  ]
}
Field Meaning
name Row label. A preset without one is skipped.
icon Optional glyph for the row.
focus Workspace to switch to once everything is launched. Omit to stay put.
apps[] What to open.
cmd Command line, run by Hyprland through sh, so ~ and $VAR expand.
workspace Where the window lands. Omit and it opens wherever you are.
class Window class used to tell whether the app is already running.

An app can also be a bare string when you only care that it runs: "apps": ["slack", "spotify"].

Replaying a preset

Clicking a preset launches every app in it, on the workspace the preset gives it. A preset is a layout, so replaying it rebuilds that layout whole — it does not quietly leave out whatever happens to be up already, and an app whose command no longer resolves costs only its own window while the rest still open.

If you would rather reuse windows you already have, turn on "Skip apps that are already running" for the widget in Setup > Plugins, or set "skipRunning": true on its entry in shell.json. Matching is by window class, so an app without one always launches either way. hyprctl clients -j | jq -r '.[].class' lists the exact strings.

Docker stacks

A Compose stack has no window, so a snapshot can never find it — the link between a project's containers and the workspace you run it on exists only in your head. So you pick it, once: the container button on a preset's row folds out every Compose project Docker knows about, with up/down telling you what is running right now. Tick the ones that belong to the preset.

A ticked stack is stored as an ordinary app entry, tagged so the panel can find it again:

{ "cmd": "docker compose -f '/srv/web/compose.yml' up -d", "compose": "/srv/web/compose.yml" }

Replay never reads the tag — a stack is just a command, which is what keeps launching dumb. Stacks are placed ahead of the apps, since they are the slowest thing to come up and the thing the apps behind them want already listening.

Two things worth knowing:

  • docker compose ls -a only lists projects Docker has containers for. A project you have never brought up will not appear; run it once and it shows.
  • Nothing waits. Everything in a preset is dispatched at the same moment, so your editor opens before the stack is healthy. up -d returns immediately and is idempotent, so this is fine until you need a healthcheck gate — that is the point at which sequencing is worth building, and not before.

Updating a preset

Re-snapshotting used to mean losing everything that was not a window: the stacks ticked into the preset and the pages typed into it. The refresh button replaces only the window rows with the workspace you are on, and leaves the name, icon, focus workspace, stacks and pages exactly as they were.

The recorder is asked for the app list rather than a new preset — record-preset --print writes it to stdout and touches no file. An empty recording is refused rather than emptying the preset.

Armed like a delete, because it throws away the window rows that are there now and a snapshot of the wrong workspace is a silent loss.

It says what it did. The row reads Updating… while the recorder runs, and a notification names the result: Updated Service System — 4 windows, kept 1 stack and 2 pages. An update can leave the row looking untouched — the windows may well be the same ones — so a silent success would be indistinguishable from a silent failure. A recording that comes back empty leaves the preset standing and says so rather than saying nothing.

Closing a preset

The inverse of launching: put the project down. Windows are closed and stacks are brought down with docker compose … down.

  • Matching is blunt on purpose: the classes the preset recorded, on the workspaces the preset uses. A second Chromium window you opened yourself on the same workspace is caught too — which is why the row names what it found before the second click: "Close 3 windows and 1 stack?"
  • The same app on a workspace the preset does not use is left alone, and so is anything the preset never mentions.
  • Windows are closed, never killed. An editor with unsaved work gets to say so.

Window geometry

  • Floating windows come back placed. Position and size are recorded and handed to Hyprland as rules on the exec itself, so the window arrives where it was instead of jumping there afterwards.
  • Tiled windows get no geometry at all. Hyprland has no dispatcher that rebuilds a split tree, so recording numbers for a tiled window would promise something replay cannot keep. What it does keep is the order things opened in, which is what decides the arrangement for the two or three windows a project usually has.

Pages

A browser keeps its tabs to itself — nothing on the command line says what was open, so a snapshot can never capture them. The pages a project needs are typed in once instead: the link button on a preset's row folds out its list, and a pasted address lands in the file the moment you press Enter.

{ "cmd": "/usr/lib/chromium/chromium --profile-directory=Default 'https://jira…'", "url": "https://jira…" }
  • They open in the browser and profile the preset already launches. A work URL landing in a personal profile is the profile-picker failure one step later, so the command is built from the preset's own browser line rather than guessed. A preset with no browser of its own defers to omarchy-launch-browser and the desktop default.
  • A pasted address without a scheme gets https://. Handed to a browser without one it would be a search, not a page.
  • The same address twice is refused, and the field keeps what you typed so you can see why nothing landed. Blank input is refused the same way.
  • Pages go last in the preset, behind the apps that need the browser up.
  • They open as tabs in one window, not a window each. Handed to a browser one at a time, every address gets its own window; handed over together they arrive as tabs. The file keeps them as separate entries so they stay individually editable — only the launch coalesces them into a single call.
  • A preset with pages does not launch its browser separately. The page call opens the browser itself, so starting it alongside would only add an empty window. The browser entry's workspace comes along with the pages, so the tabs still land where the browser was recorded. Remove every page and the browser goes back to launching on its own.
  • Esc closes the editor, the × on a row removes that page.

Snapshotting a workspace

"Snapshot this workspace" turns the windows on the workspace you are looking at into a preset, named after the project directory its terminals were sitting in — a preset called "Workspace 1 2026-08-16 09:00" is a preset you rename every time. The timestamp is the fallback for when there was no directory to go on, and a name you pass yourself always wins. The workspace is kept on every app and as the preset's focus, so replaying the snapshot puts the windows back where you took them and leaves you on that workspace.

Keep an eye on editors left open on workspace-presets.json. Their buffer predates every panel edit made since, and writing it reverts renames and brings deleted presets back. Reload (:e! in nvim) before saving.

From a terminal:

scripts/record-preset          # the workspace you are on
scripts/record-preset "Web"    # named
scripts/record-preset --all    # every workspace at once

--all has no row in the panel on purpose: sweeping the whole desktop produces a preset carrying your music player, your chat client, and yesterday's project, which is a session restore rather than a project. It stays one flag away for the once-in-a-while case.

Snapshots read each window's real command line from /proc/<pid>/cmdline, which is more honest than guessing from the window class, with two caveats worth knowing:

  • Arguments are joined on spaces, so an argument containing a space comes back unquoted.
  • Single-instance apps report their daemon's command line, which is started with the flags that tell it not to put a window on screen. Those are stripped on the way in — --initial-window=false and --gtk-single-instance=… — because replaying them gives a preset that runs and opens nothing, which looks exactly like a broken feature.
  • What is left is still the running process, not a launcher. Browsers and Electron apps re-run into their existing instance and focus rather than open a second window. If you want a preset to open a new window, say so explicitly: chromium --new-window https://… instead of the recorded line. Terminal directories are handled for you — see below.

Special workspaces (scratchpad) are left out.

Terminal working directories

A terminal that reopens in / is a terminal you have to cd in every morning, which is most of what a project preset is supposed to save you. The directory is not on the window — a terminal running every window in one process has a single /proc entry — so windows are matched to the shells inside it by title:

Window title Matched shell
claude the one whose child process is claude
~ the one sitting in $HOME
sportson/sportson-view SV-175 the one whose path ends in sportson/sportson-view

Everything from the first space onward is decoration — Omarchy's prompt appends the git branch behind a Nerd Font glyph — so it is cut before matching. Elided titles (…/work/project) match on the tail that survives. A matched shell is consumed, so two windows never claim the same one, and the result is recorded as --working-directory=….

  • One entry per terminal window, not per directory. Two terminals in the same directory is a normal layout — one tailing logs, one you work in — so both come back.

  • A window whose title matches nothing is recorded without a directory. You still get the terminal, it just opens where your shell would normally start.

  • Every snapshot leaves a record of what it saw, one line per window, in ~/.cache/omarchy-workspace-presets/last-snapshot.log:

    ws1 ghostty title=~              dir=/home/you            run=<none>
    ws1 ghostty title=✳ Claude Code  dir=/home/you/Dev/api    run=claude --continue
    ws3 ghostty title=zsh            dir=/home/you/Dev/api    run=docker compose logs -f
    

    When a preset opens somewhere unexpected, the answer is in there rather than in a guess.

  • The snapshot notification names the directories it caught, and counts the terminals it caught none for:

    Saved "Workspace 1" — 4 apps from workspace 1
    in: ~, ~/Dev/work/sportson, ~
    1 terminal with no directory
    

    A shell sits where you left it, which is not always where you believe you are working — a tool open on a project does not move the shell that started it. The terminal's own title bar is the same truth, read before you snapshot rather than after you replay.

What the terminal was running

A terminal that comes back empty is only half the layout — the log tail and the assistant session were the point of the window. Whatever the shell had in the foreground is recorded and started again:

{ "cmd": "ghostty --working-directory=~/Dev/api -e docker compose logs -f", "workspace": 2 }
  • Claude Code is recorded as claude --continue, which reopens the conversation that window was in. Sessions are kept per directory, so the directory does the addressing. Two Claude windows in the same directory both resume the newest conversation — a running session's id is not readable from the outside.
  • The window closes when the command ends. -e runs the command instead of a shell, so quitting the program closes the terminal with it. Drop the -e … from the line if you would rather have a shell that stays.
  • Whatever was in the foreground is what comes back, so a preset taken mid build replays that build. Snapshot when the workspace looks the way you want it to return.
  • A title nobody can read is still matched. A shell alias makes the window say dc logs -f while the process is plainly docker, and no amount of title reading bridges that. It does not have to: once the identifiable windows are bound, the windows left over and the sessions left over are the same set, so they are paired off. Done only when the two counts agree — otherwise something is unaccounted for, and a guess would be somebody's wrong project.
  • Restored windows re-record cleanly. A window started as -e claude has no shell inside it, only the command, and Claude Code renames the window to "✳ Claude Code" — so neither the title nor a shell lookup finds it. A terminal holding exactly one window needs neither: the single session inside it is that window's, whatever the title says. An existing -e … on the line is cut before the current one is written, so re-recording never stacks commands.

Browser profiles

Chromium with more than one profile and no --profile-directory opens the profile chooser instead of a browser, which stops a preset dead. The profile is not readable from the running process, so the recorder appends the last profile you used — wrong sometimes, but a browser every time beats a picker every time. Edit the line when it guesses wrong.

What a snapshot cannot bring back

A snapshot restores windows, not sessions. A terminal that was running claude or tailing docker compose logs -f comes back as an empty shell in the right directory, and a browser comes back on its own session restore rather than the tabs you had. When a preset should always start something, say so in the command:

{ "cmd": "ghostty --working-directory=~/Dev/api -e docker compose logs -f", "workspace": 2 }

Other terminals and browsers

The half that reads your desktop is portable already: shells, working directories, foreground commands and the window-to-session matching all come from /proc and process trees, and every terminal runs a shell as a child.

The half that writes a command line is not, and the differences fail silently.

Terminal Directory flag Verified
ghostty --working-directory=DIR yes
foot --working-directory=DIR yes
alacritty --working-directory DIR yes

-e needs no table: ghostty and alacritty implement it, and foot accepts it for xterm compatibility.

An unrecognised terminal is recorded without a directory. A preset that opens a terminal in the wrong place is a nuisance; one that writes ghostty syntax at kitty is broken. Adding a terminal is one line in working_directory_flag in scripts/record-preset, and a line in scripts/test-terminals to hold it there. kitty and wezterm are absent on purpose — their flags were not verifiable on the machine this was built on, and guessing is what this section exists to prevent.

Browser profiles work the same way: Chromium, Chrome, Brave, Edge and Vivaldi take --profile-directory=NAME with the last-used profile in their own Local State; Firefox names profiles with -P and keeps them in profiles.ini, so it gets its own branch rather than a row that almost fits.

The icon set is fixed rather than free text, and every glyph in it was rendered in the bar's font and checked by eye before it went in — the only way to know a glyph is not a blank box on the machine it ships to.

Both tables are pure string work, so scripts/test-terminals checks them without any of these programs installed — which is the point, since most of them are not.

How launching works

This Hyprland evaluates hyprctl dispatch as Lua, so the classic exec [workspace 3 silent] cmd form no longer applies. Each app becomes:

hyprctl dispatch 'hl.dsp.exec_cmd("cursor ~/code/web", { workspace = "2 silent" })'

silent sits inside the workspace rule rather than being its own effect — Hyprland rejects silent = true as an unknown effect — and it is what keeps a five-app preset from dragging the screen through five workspaces on the way up.

Development

omarchy plugin validate .
qmllint -I "$OMARCHY_PATH/shell" BarWidget.qml Panel.qml
scripts/test-model                 # quoting, skip detection, file rewrites, stacks
scripts/test-terminals             # terminal and browser command-line shapes
scripts/test-wiring                # every root.x() the panel calls exists

Saving any file under ~/.config/omarchy/plugins/ hot-reloads the plugin. If a panel stops drawing after several reloads in a row, that is the reload state and not your code — omarchy-restart-shell clears it.

Remove

omarchy plugin remove io.github.monswiklund.workspace-presets