@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/row-layout.js
ADDED
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows management: how a project row is built — its rows of buttons, top to bottom, what is inside each
|
|
3
|
+
* button's menu, and the actions someone adds — set in Commands → Buttons.
|
|
4
|
+
*
|
|
5
|
+
* Global, one file for the machine: core/config/row-actions.yml. It holds only what someone changed;
|
|
6
|
+
* the defaults live here, so a button added to the dashboard later appears in its default row until
|
|
7
|
+
* someone moves it, and deleting the file is a full reset. sync-tooling.sh copies only
|
|
8
|
+
* workspace.*.settings.yml into core/config, so a --pull never touches it.
|
|
9
|
+
*
|
|
10
|
+
* rows: [[ids…], [ids…], …] the rows of buttons, top to bottom (the first sits beside the badges)
|
|
11
|
+
* hidden: [ids…] buttons not shown at all
|
|
12
|
+
* show: { <id>: icon | text } a button drawn as its icon alone, or as its label alone — absent
|
|
13
|
+
* means both, which is what every button is until someone says
|
|
14
|
+
* menus: { <id>: { groups, columns: [[groups…], …], order: { <group>: [entries] }, hidden_groups, hidden_entries } }
|
|
15
|
+
* custom: [action…] actions someone added (below)
|
|
16
|
+
*
|
|
17
|
+
* An action borrows Rundeck's job model, cut down to one user on one machine:
|
|
18
|
+
*
|
|
19
|
+
* id, label, button, group, description
|
|
20
|
+
* when: [ddev, composer, "!workspace:products"] every clause must match; ! negates (a node filter)
|
|
21
|
+
* options: [{ name, label, value, values, enforced, required, regex }] asked for before it runs
|
|
22
|
+
* steps: ["cmd", { command, on_error }, { use }] or a single `command`; `use: <id>` runs a command of the
|
|
23
|
+
* catalog (commands.js) on the row's project
|
|
24
|
+
* keepgoing: false → stop at the first failed step; true → run them all and say which failed
|
|
25
|
+
* confirm: true → asks in a dialog first, like every destructive entry in the dashboard
|
|
26
|
+
* open: toast (the default: a line at the top links to the session) | screen (its terminal opens at once)
|
|
27
|
+
*
|
|
28
|
+
* A value reaches a step only through {{opt.name}}, checked against the option and put in single quotes.
|
|
29
|
+
* No step can contain $ or a backtick: the screen runner hands the whole line to an outer shell, which
|
|
30
|
+
* would expand them before the step runs. Every run is recorded (row-actions.history.jsonl) and its result
|
|
31
|
+
* read back from the markers its line prints.
|
|
32
|
+
*
|
|
33
|
+
* Every menu is rearranged the same way, without each menu knowing: its <ul class="uk-dropdown-nav">
|
|
34
|
+
* is split into top-level <li>, a group starts at a header or after a divider, and a group is named by
|
|
35
|
+
* its header's data-group, else the header text before " · ". An entry is named by its data-entry, else
|
|
36
|
+
* its text. Markup this cannot split cleanly is returned untouched, so a menu is never broken by it.
|
|
37
|
+
* Hiding is presentation only: a hidden entry's route still answers, exactly as it did before. The dashboard's
|
|
38
|
+
* own commands can be moved and hidden, never deleted; only actions someone added can be edited or deleted.
|
|
39
|
+
*
|
|
40
|
+
* What the page can offer to reorder or hide is what has been SEEN on project rows: every menu this
|
|
41
|
+
* rearranges is recorded in core/config/row-actions.seen.json.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
const yaml = require('js-yaml');
|
|
47
|
+
|
|
48
|
+
const { esc } = require('./html');
|
|
49
|
+
const { CONFIG_DIR, ROOT } = require('./workspaces');
|
|
50
|
+
|
|
51
|
+
const BUILTIN_BUTTONS = [
|
|
52
|
+
{ id: 'ddev', label: 'DDEV', row: 0 },
|
|
53
|
+
{ id: 'tests', label: 'Tests', row: 0 },
|
|
54
|
+
// These three lay out their own menus (SCREENS is filled in by a request), so they take no column setting.
|
|
55
|
+
{ id: 'screens', label: 'SCREENS', row: 1, columns: false },
|
|
56
|
+
{ id: 'composer', label: 'COMPOSER', row: 1, columns: false },
|
|
57
|
+
{ id: 'node', label: 'NODE', row: 1, columns: false },
|
|
58
|
+
// Single buttons, with no menu inside to arrange or to add actions to.
|
|
59
|
+
{ id: 'backup', label: 'Backup', row: 1, menu: false },
|
|
60
|
+
{ id: 'clone', label: 'Clone', row: 1, menu: false },
|
|
61
|
+
{ id: 'graph', label: 'Graph', row: 1 },
|
|
62
|
+
{ id: 'rag', label: 'RAG', row: 1 },
|
|
63
|
+
{ id: 'more', label: 'More', row: 1 },
|
|
64
|
+
];
|
|
65
|
+
const BUILTIN_IDS = new Set(BUILTIN_BUTTONS.map((b) => b.id));
|
|
66
|
+
const DEFAULT_ROWS = [0, 1].map((row) => BUILTIN_BUTTONS.filter((b) => b.row === row).map((b) => b.id));
|
|
67
|
+
|
|
68
|
+
/** A button a plugin adds (plugin-loader's addRowMenu): known to the layout like a built-in one. */
|
|
69
|
+
function registerButton(spec) {
|
|
70
|
+
if (BUILTIN_IDS.has(spec.id)) return;
|
|
71
|
+
const row = spec.row === 1 ? 1 : 0;
|
|
72
|
+
const entry = { id: spec.id, label: spec.label || spec.id, row };
|
|
73
|
+
if (spec.columns === false) entry.columns = false;
|
|
74
|
+
if (spec.menu === false) entry.menu = false;
|
|
75
|
+
BUILTIN_BUTTONS.push(entry);
|
|
76
|
+
BUILTIN_IDS.add(spec.id);
|
|
77
|
+
DEFAULT_ROWS[row].push(spec.id);
|
|
78
|
+
}
|
|
79
|
+
const MAX_ROWS = 6;
|
|
80
|
+
const MAX_COLUMNS = 4;
|
|
81
|
+
|
|
82
|
+
// The base clauses of `when`. Each can take a ! in front, and workspace:<name> matches one workspace.
|
|
83
|
+
const WHENS = {
|
|
84
|
+
any: 'every folder',
|
|
85
|
+
ddev: 'DDEV projects',
|
|
86
|
+
composer: 'folders with a composer.json',
|
|
87
|
+
node: 'folders with a package.json',
|
|
88
|
+
git: 'folders with their own .git',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
92
|
+
const CUSTOM_ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
93
|
+
// A label goes into the session's closing echo "…" as well as the page: no quote, $, backtick or backslash.
|
|
94
|
+
const LABEL_RE = /^[A-Za-z0-9 _.()+:/@#,&-]{1,60}$/;
|
|
95
|
+
const GROUP_RE = /^[A-Za-z0-9 _.()+:/-]{1,40}$/;
|
|
96
|
+
const KEY_RE = /^[^\r\n<>]{1,120}$/;
|
|
97
|
+
const WHEN_RE = /^!?(any|ddev|composer|node|git|workspace:[a-z0-9_-]{1,40})$/;
|
|
98
|
+
const OPTION_NAME_RE = /^[a-z][a-z0-9_]{0,30}$/;
|
|
99
|
+
const PLACEHOLDER_RE = /\{\{opt\.([a-z][a-z0-9_]{0,30})\}\}/g;
|
|
100
|
+
// A step that uses a command of the catalog names it by its id (commands.js).
|
|
101
|
+
const USE_RE = /^[a-z0-9][a-z0-9-]{0,44}$/;
|
|
102
|
+
|
|
103
|
+
const lc = (s) => String(s).toLowerCase();
|
|
104
|
+
|
|
105
|
+
// YAML aliases let a few lines stand for billions of values (each level pointing at the one below nine times).
|
|
106
|
+
// Anything walking such a document — or turning one of its lists into text — never finishes, and this
|
|
107
|
+
// dashboard serves every page from one thread. A definition is walked here first, visit by visit, and refused
|
|
108
|
+
// once it passes a size no real layout comes near.
|
|
109
|
+
const MAX_DEFINITION_NODES = 50000;
|
|
110
|
+
function withinBudget(value, budget = MAX_DEFINITION_NODES) {
|
|
111
|
+
const stack = [value];
|
|
112
|
+
let visits = 0;
|
|
113
|
+
while (stack.length) {
|
|
114
|
+
const v = stack.pop();
|
|
115
|
+
visits += 1;
|
|
116
|
+
if (visits > budget) return false;
|
|
117
|
+
if (Array.isArray(v)) { for (let i = 0; i < v.length; i += 1) stack.push(v[i]); } else if (v && typeof v === 'object') { for (const k of Object.keys(v)) stack.push(v[k]); }
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const hasMenu = (id) => (BUILTIN_BUTTONS.find((b) => b.id === id) || {}).menu !== false;
|
|
122
|
+
const takesColumns = (id) => hasMenu(id) && (BUILTIN_BUTTONS.find((b) => b.id === id) || {}).columns !== false;
|
|
123
|
+
|
|
124
|
+
function layoutFile() { return process.env.ROW_ACTIONS_FILE || path.join(CONFIG_DIR, 'row-actions.yml'); }
|
|
125
|
+
function seenFile() { return process.env.ROW_ACTIONS_SEEN_FILE || path.join(CONFIG_DIR, 'row-actions.seen.json'); }
|
|
126
|
+
function historyFile() { return process.env.ROW_ACTIONS_HISTORY_FILE || path.join(CONFIG_DIR, 'row-actions.history.jsonl'); }
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------- actions
|
|
129
|
+
|
|
130
|
+
function whenList(w) {
|
|
131
|
+
const list = Array.isArray(w) ? w : [w];
|
|
132
|
+
const out = list.map((x) => (x === undefined || x === null ? '' : String(x).trim())).filter(Boolean);
|
|
133
|
+
return out.length ? out : ['any'];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function describeWhen(list) {
|
|
137
|
+
return whenList(list).map((c) => {
|
|
138
|
+
const neg = c.startsWith('!');
|
|
139
|
+
const k = neg ? c.slice(1) : c;
|
|
140
|
+
const text = k.startsWith('workspace:') ? `the ${k.slice(10)} workspace` : (WHENS[k] || k);
|
|
141
|
+
return neg ? `not ${text}` : text;
|
|
142
|
+
}).join(' and ');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function whenMatches(when, ctx = {}) {
|
|
146
|
+
return whenList(when).every((c) => {
|
|
147
|
+
const neg = c.startsWith('!');
|
|
148
|
+
const k = neg ? c.slice(1) : c;
|
|
149
|
+
let v;
|
|
150
|
+
if (k === 'any') v = true;
|
|
151
|
+
else if (k.startsWith('workspace:')) v = ctx.key === k.slice(10);
|
|
152
|
+
else v = !!({ ddev: ctx.isDdev, composer: ctx.hasComposer, node: ctx.hasNode, git: ctx.hasGit })[k];
|
|
153
|
+
return neg ? !v : v;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function cleanCustom(c) {
|
|
158
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
159
|
+
const str = (v) => (v === undefined || v === null ? '' : String(v)).trim();
|
|
160
|
+
const rawSteps = Array.isArray(c.steps) ? c.steps : (c.command !== undefined ? [c.command] : []);
|
|
161
|
+
const steps = rawSteps.map((s) => ((s && typeof s === 'object')
|
|
162
|
+
? { command: str(s.command), onError: str(s.on_error !== undefined ? s.on_error : s.onError), use: str(s.use) }
|
|
163
|
+
: { command: str(s), onError: '', use: '' }));
|
|
164
|
+
const options = (Array.isArray(c.options) ? c.options : []).map((o) => ((o && typeof o === 'object') ? {
|
|
165
|
+
name: str(o.name),
|
|
166
|
+
label: str(o.label),
|
|
167
|
+
value: str(o.value),
|
|
168
|
+
values: Array.isArray(o.values) ? o.values.map(str) : [],
|
|
169
|
+
enforced: o.enforced === true,
|
|
170
|
+
required: o.required === true,
|
|
171
|
+
regex: str(o.regex),
|
|
172
|
+
} : null));
|
|
173
|
+
return {
|
|
174
|
+
id: str(c.id),
|
|
175
|
+
label: str(c.label),
|
|
176
|
+
button: lc(str(c.button)),
|
|
177
|
+
group: str(c.group) || 'Custom',
|
|
178
|
+
description: str(c.description),
|
|
179
|
+
when: whenList(c.when),
|
|
180
|
+
confirm: c.confirm === true,
|
|
181
|
+
keepgoing: c.keepgoing === true,
|
|
182
|
+
open: c.open === 'screen' ? 'screen' : 'toast',
|
|
183
|
+
steps,
|
|
184
|
+
options,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function commandRefusal(cmd, what) {
|
|
189
|
+
if (!cmd || cmd.length > 400) return `${what} is one line of up to 400 characters`;
|
|
190
|
+
if (/[\r\n\0]/.test(cmd)) return `${what} must be one line`;
|
|
191
|
+
if (/[$`]/.test(cmd)) {
|
|
192
|
+
return `${what} cannot contain $ or a backtick: the screen runner hands the line to an outer shell, which would expand them before it runs (for a value, use an option and {{opt.name}})`;
|
|
193
|
+
}
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function valueRefusal(v, what) {
|
|
198
|
+
if (String(v).length > 200) return `${what} is up to 200 characters`;
|
|
199
|
+
if (/[\r\n\0$`]/.test(v)) return `${what} cannot contain $, a backtick or a line break`;
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Why this action must not be saved or run, or '' when it may. */
|
|
204
|
+
function customRefusal(e) {
|
|
205
|
+
if (!e) return 'it is not an action (a mapping with id, label, button, steps…)';
|
|
206
|
+
if (!CUSTOM_ID_RE.test(e.id)) return 'the id is lower-case letters, digits and dashes';
|
|
207
|
+
if (!LABEL_RE.test(e.label)) return 'the label is letters, digits, spaces and . ( ) + : / @ # , & - (up to 60)';
|
|
208
|
+
if (!ID_RE.test(e.button)) return 'the button is a lower-case name, like ddev, composer or tools';
|
|
209
|
+
if (!hasMenu(e.button)) return `${buttonLabel(e.button)} is a single button with no menu — put the action in More, or in a button of your own`;
|
|
210
|
+
if (!GROUP_RE.test(e.group)) return 'the group is letters, digits and spaces (up to 40)';
|
|
211
|
+
if (e.description.length > 200 || /[\r\n]/.test(e.description)) return 'the description is one line of up to 200 characters';
|
|
212
|
+
if (e.when.some((w) => !WHEN_RE.test(w))) return '"when" is any, ddev, composer, node, git or workspace:<name>, each optionally with ! in front';
|
|
213
|
+
if (!e.steps.length || e.steps.length > 20) return 'an action has 1 to 20 steps';
|
|
214
|
+
for (const [i, s] of e.steps.entries()) {
|
|
215
|
+
const what = e.steps.length === 1 ? 'the command' : `step ${i + 1}`;
|
|
216
|
+
if (s.use && s.command) return `${what} has both use and a command line — a step is one or the other`;
|
|
217
|
+
if (s.use && !USE_RE.test(s.use)) return `${what} uses "${s.use}", which is not a command id`;
|
|
218
|
+
const r = (s.use ? '' : commandRefusal(s.command, what)) || (s.onError ? commandRefusal(s.onError, `${what}'s on_error`) : '');
|
|
219
|
+
if (r) return r;
|
|
220
|
+
}
|
|
221
|
+
if (e.options.length > 10) return 'an action asks for up to 10 options';
|
|
222
|
+
const names = new Set();
|
|
223
|
+
for (const o of e.options) {
|
|
224
|
+
if (!o) return 'an option is a mapping: name, and optionally label, value, values, enforced, required, regex';
|
|
225
|
+
if (!OPTION_NAME_RE.test(o.name) || names.has(o.name)) return `option names are unique lower-case names (${o.name || 'empty'})`;
|
|
226
|
+
names.add(o.name);
|
|
227
|
+
if (o.label && !LABEL_RE.test(o.label)) return `option ${o.name}: the label is letters, digits and spaces (up to 60)`;
|
|
228
|
+
if (o.values.length > 50) return `option ${o.name}: up to 50 values`;
|
|
229
|
+
for (const v of [o.value, ...o.values]) { const r = valueRefusal(v, `option ${o.name}`); if (r) return r; }
|
|
230
|
+
if (o.enforced && !o.values.length) return `option ${o.name} is enforced but lists no values`;
|
|
231
|
+
if (o.regex) {
|
|
232
|
+
if (o.regex.length > 200) return `option ${o.name}: the regex is up to 200 characters`;
|
|
233
|
+
try { new RegExp(o.regex); } catch (_) { return `option ${o.name}: the regex does not compile`; }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const s of e.steps) {
|
|
237
|
+
for (const t of [s.command, s.onError]) {
|
|
238
|
+
for (const m of t.matchAll(PLACEHOLDER_RE)) if (!names.has(m[1])) return `{{opt.${m[1]}}} is used but there is no option called ${m[1]}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Why this value must not be used for this option, or '' when it may. */
|
|
245
|
+
function optionValueRefusal(o, raw) {
|
|
246
|
+
const v = raw === undefined || raw === null ? '' : String(raw);
|
|
247
|
+
const name = o.label || o.name;
|
|
248
|
+
if (!v) return o.required ? `${name} is required` : '';
|
|
249
|
+
const r = valueRefusal(v, name);
|
|
250
|
+
if (r) return r;
|
|
251
|
+
if (o.enforced && !o.values.includes(v)) return `${name} must be one of: ${o.values.join(', ')}`;
|
|
252
|
+
if (o.regex && !new RegExp(o.regex).test(v)) return `${name} does not match ${o.regex}`;
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The values a run uses: what was submitted, else each option's default — or the first thing wrong. */
|
|
257
|
+
function resolveOptions(entry, form = {}) {
|
|
258
|
+
const values = {};
|
|
259
|
+
for (const o of entry.options) {
|
|
260
|
+
const given = form[`opt_${o.name}`];
|
|
261
|
+
const v = (given === undefined || given === null || String(given) === '') ? o.value : String(given);
|
|
262
|
+
const why = optionValueRefusal(o, v);
|
|
263
|
+
if (why) return { error: why };
|
|
264
|
+
values[o.name] = v;
|
|
265
|
+
}
|
|
266
|
+
return { values };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const shq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The line an action runs. Steps are joined with && (stop at the first failure) or run one after another
|
|
273
|
+
* (keepgoing); a step's on_error runs when it fails and its result becomes the step's, as in Rundeck.
|
|
274
|
+
* The markers it prints are how the Activity tab knows what happened, without reading an exit code — `$?`
|
|
275
|
+
* is not available to a line that may not contain `$`.
|
|
276
|
+
*/
|
|
277
|
+
function actionCommandLine(entry, values = {}) {
|
|
278
|
+
const fill = (cmd) => cmd.replace(PLACEHOLDER_RE, (_, n) => shq(values[n] === undefined ? '' : values[n]));
|
|
279
|
+
const parts = entry.steps.map((s, i) => {
|
|
280
|
+
const run = `{ ${fill(s.command)} ; }`;
|
|
281
|
+
const handled = s.onError
|
|
282
|
+
? `{ ${run} || { echo "ROW-ACTION step ${i + 1} failed: running its on_error" ; ${fill(s.onError)} ; } ; }`
|
|
283
|
+
: run;
|
|
284
|
+
return entry.keepgoing ? `{ ${handled} || echo "ROW-ACTION step ${i + 1} failed" ; }` : handled;
|
|
285
|
+
});
|
|
286
|
+
return `{ ${parts.join(entry.keepgoing ? ' ; ' : ' && ')} ; } && echo "ROW-ACTION succeeded" || echo "ROW-ACTION failed"`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** What a run's output says happened. */
|
|
290
|
+
function runStatus(text, live) {
|
|
291
|
+
const t = String(text || '');
|
|
292
|
+
const failedSteps = [...t.matchAll(/ROW-ACTION step (\d+) failed(?!:)/g)].map((m) => m[1]);
|
|
293
|
+
if (/ROW-ACTION failed/.test(t)) return { state: 'failed', note: '' };
|
|
294
|
+
if (/ROW-ACTION succeeded/.test(t)) {
|
|
295
|
+
return failedSteps.length ? { state: 'partly', note: `step ${failedSteps.join(', ')} failed` } : { state: 'succeeded', note: '' };
|
|
296
|
+
}
|
|
297
|
+
return live ? { state: 'running', note: '' } : { state: 'unknown', note: 'no result in its output' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function slug(label) {
|
|
301
|
+
return lc(label).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'action';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------- steps that use a command
|
|
305
|
+
|
|
306
|
+
// commands.js requires this file, so the catalog is required when it is first needed rather than at the top.
|
|
307
|
+
const catalog = () => require('./commands');
|
|
308
|
+
|
|
309
|
+
/** A step as a person writes it in the steps box: its command line, or use: <command id>. */
|
|
310
|
+
function stepText(s) {
|
|
311
|
+
return s.use ? `use: ${s.use}` : s.command;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The commands an action's steps use, in order; `cmd` is null for one that is not in the catalog. */
|
|
315
|
+
function usedCommands(entry) {
|
|
316
|
+
return entry.steps.filter((s) => s.use).map((s) => ({ id: s.use, cmd: catalog().findCommand(s.use) }));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Why an action's use: steps cannot run from a project row, or '' when they can. */
|
|
320
|
+
function useRefusal(entry) {
|
|
321
|
+
for (const [i, s] of entry.steps.entries()) {
|
|
322
|
+
if (!s.use) continue;
|
|
323
|
+
const cmd = catalog().findCommand(s.use);
|
|
324
|
+
if (!cmd) return `step ${i + 1} uses ${s.use}, which is not a command (it may have been deleted)`;
|
|
325
|
+
if (cmd.on === 'file') return `step ${i + 1} uses ${cmd.label}, which runs on a file, and a project row gives no file to run it on`;
|
|
326
|
+
}
|
|
327
|
+
return '';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The options a run asks for: the action's own, then those of the commands it uses (the action's own win a shared name). */
|
|
331
|
+
function effectiveOptions(entry) {
|
|
332
|
+
const out = entry.options.filter(Boolean);
|
|
333
|
+
const names = new Set(out.map((o) => o.name));
|
|
334
|
+
for (const { cmd } of usedCommands(entry)) {
|
|
335
|
+
for (const o of (cmd ? cmd.options : [])) {
|
|
336
|
+
if (o && !names.has(o.name)) { names.add(o.name); out.push(o); }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The command picker of an "Add an action" box: every command a project row can run. */
|
|
343
|
+
function commandPickerHtml(name) {
|
|
344
|
+
const usable = catalog().loadCatalog().commands.filter((c) => c.on !== 'file');
|
|
345
|
+
const groups = [...new Set(usable.map((c) => c.group))];
|
|
346
|
+
return `<label>Use a command <select class="uk-select uk-form-small" name="${name}">
|
|
347
|
+
<option value="">None — type the commands below</option>
|
|
348
|
+
${groups.map((g) => `<optgroup label="${esc(g)}">${usable.filter((c) => c.group === g)
|
|
349
|
+
.map((c) => `<option value="${esc(c.id)}">${esc(c.label)}</option>`).join('')}</optgroup>`).join('')}
|
|
350
|
+
</select></label>`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------- the layout
|
|
354
|
+
|
|
355
|
+
/** A layout from whatever the file held: unknown ids dropped, every button placed exactly once. */
|
|
356
|
+
function normalizeLayout(raw) {
|
|
357
|
+
const r = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {};
|
|
358
|
+
const strList = (v, re) => (Array.isArray(v) ? v : [])
|
|
359
|
+
.filter((x) => typeof x === 'string' || typeof x === 'number').map((x) => String(x).trim()).filter((x) => re.test(x));
|
|
360
|
+
|
|
361
|
+
const custom = [];
|
|
362
|
+
const customIds = new Set();
|
|
363
|
+
for (const c of Array.isArray(r.custom) ? r.custom : []) {
|
|
364
|
+
const e = cleanCustom(c);
|
|
365
|
+
if (!e || customRefusal(e) || customIds.has(e.id)) continue;
|
|
366
|
+
customIds.add(e.id);
|
|
367
|
+
custom.push(e);
|
|
368
|
+
}
|
|
369
|
+
const customButtons = [...new Set(custom.map((c) => c.button).filter((b) => !BUILTIN_IDS.has(b)))];
|
|
370
|
+
const known = new Set([...BUILTIN_IDS, ...customButtons]);
|
|
371
|
+
|
|
372
|
+
const placed = new Set();
|
|
373
|
+
const hidden = strList(r.hidden, ID_RE).filter((id) => known.has(id) && !placed.has(id) && placed.add(id));
|
|
374
|
+
// `lines` is what the first version of this file called rows.
|
|
375
|
+
const rawRows = (Array.isArray(r.rows) ? r.rows : (Array.isArray(r.lines) ? r.lines : [])).slice(0, MAX_ROWS);
|
|
376
|
+
const rows = rawRows.map((row) => strList(Array.isArray(row) ? row : [], ID_RE).filter((id) => known.has(id) && !placed.has(id) && placed.add(id)));
|
|
377
|
+
if (!rows.length) rows.push([]);
|
|
378
|
+
// A button the file does not mention — new since it was saved, or never moved — keeps its default row.
|
|
379
|
+
for (const b of BUILTIN_BUTTONS) {
|
|
380
|
+
if (placed.has(b.id)) continue;
|
|
381
|
+
while (rows.length <= b.row) rows.push([]);
|
|
382
|
+
rows[b.row].push(b.id);
|
|
383
|
+
placed.add(b.id);
|
|
384
|
+
}
|
|
385
|
+
for (const id of customButtons) {
|
|
386
|
+
if (placed.has(id)) continue;
|
|
387
|
+
rows[Math.min(1, rows.length - 1)].push(id);
|
|
388
|
+
placed.add(id);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const menus = {};
|
|
392
|
+
const rawMenus = (r.menus && typeof r.menus === 'object') ? r.menus : {};
|
|
393
|
+
for (const [id, m] of Object.entries(rawMenus)) {
|
|
394
|
+
if (!ID_RE.test(id) || !m || typeof m !== 'object') continue;
|
|
395
|
+
menus[id] = {
|
|
396
|
+
groups: strList(m.groups, KEY_RE).map(lc),
|
|
397
|
+
hiddenGroups: strList(m.hidden_groups, KEY_RE).map(lc),
|
|
398
|
+
hiddenEntries: strList(m.hidden_entries, KEY_RE).map(lc),
|
|
399
|
+
columns: (Array.isArray(m.columns) ? m.columns : []).slice(0, MAX_COLUMNS).map((col) => strList(Array.isArray(col) ? col : [], KEY_RE).map(lc)),
|
|
400
|
+
order: Object.fromEntries(Object.entries((m.order && typeof m.order === 'object' && !Array.isArray(m.order)) ? m.order : {})
|
|
401
|
+
.filter(([g]) => KEY_RE.test(g)).map(([g, keys]) => [lc(g), strList(keys, KEY_RE).map(lc)]).filter(([, keys]) => keys.length)),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// How much of a button is drawn. The same three answers the navigation gives, and deliberately the
|
|
405
|
+
// same word for them: a row of buttons and a rail of entries are the same question about room.
|
|
406
|
+
const show = Object.fromEntries(Object.entries((r.show && typeof r.show === 'object' && !Array.isArray(r.show)) ? r.show : {})
|
|
407
|
+
.filter(([id, how]) => ID_RE.test(id) && known.has(id) && (how === 'icon' || how === 'text')));
|
|
408
|
+
|
|
409
|
+
return { rows, hidden, show, menus, custom };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function customToDoc(c) {
|
|
413
|
+
const doc = { id: c.id, label: c.label, button: c.button, group: c.group };
|
|
414
|
+
if (c.description) doc.description = c.description;
|
|
415
|
+
doc.when = c.when.length === 1 ? c.when[0] : [...c.when];
|
|
416
|
+
if (c.confirm) doc.confirm = true;
|
|
417
|
+
if (c.keepgoing) doc.keepgoing = true;
|
|
418
|
+
if (c.open === 'screen') doc.open = 'screen';
|
|
419
|
+
if (c.options.length) {
|
|
420
|
+
doc.options = c.options.map((o) => {
|
|
421
|
+
const od = { name: o.name };
|
|
422
|
+
if (o.label) od.label = o.label;
|
|
423
|
+
if (o.value) od.value = o.value;
|
|
424
|
+
if (o.values.length) od.values = [...o.values];
|
|
425
|
+
if (o.enforced) od.enforced = true;
|
|
426
|
+
if (o.required) od.required = true;
|
|
427
|
+
if (o.regex) od.regex = o.regex;
|
|
428
|
+
return od;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const stepDoc = (s) => {
|
|
432
|
+
if (s.use) return s.onError ? { use: s.use, on_error: s.onError } : { use: s.use };
|
|
433
|
+
return s.onError ? { command: s.command, on_error: s.onError } : s.command;
|
|
434
|
+
};
|
|
435
|
+
if (c.steps.length === 1 && !c.steps[0].onError && !c.steps[0].use) doc.command = c.steps[0].command;
|
|
436
|
+
else doc.steps = c.steps.map(stepDoc);
|
|
437
|
+
return doc;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The layout as the file stores it. */
|
|
441
|
+
function toDoc(layout) {
|
|
442
|
+
return {
|
|
443
|
+
rows: layout.rows.map((l) => [...l]),
|
|
444
|
+
hidden: [...layout.hidden],
|
|
445
|
+
...(Object.keys(layout.show || {}).length ? { show: { ...layout.show } } : {}),
|
|
446
|
+
menus: Object.fromEntries(Object.entries(layout.menus)
|
|
447
|
+
.filter(([, m]) => m.groups.length || m.hiddenGroups.length || m.hiddenEntries.length
|
|
448
|
+
|| Object.keys(m.order || {}).length || (m.columns && m.columns.length > 1))
|
|
449
|
+
.map(([id, m]) => [id,
|
|
450
|
+
{ groups: [...m.groups], hidden_groups: [...m.hiddenGroups], hidden_entries: [...m.hiddenEntries],
|
|
451
|
+
...(m.columns && m.columns.length > 1 ? { columns: m.columns.map((c) => [...c]) } : {}),
|
|
452
|
+
...(Object.keys(m.order || {}).length ? { order: Object.fromEntries(Object.entries(m.order).map(([g, k]) => [g, [...k]])) } : {}) }])),
|
|
453
|
+
custom: layout.custom.map(customToDoc),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function knownButtonIds(layout) {
|
|
458
|
+
return [...BUILTIN_IDS, ...new Set(layout.custom.map((c) => c.button).filter((b) => !BUILTIN_IDS.has(b)))];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function buttonLabel(id) {
|
|
462
|
+
return (BUILTIN_BUTTONS.find((b) => b.id === id) || { label: String(id).toUpperCase() }).label;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let cache = { at: 0, mtime: -1, file: '', layout: null };
|
|
466
|
+
|
|
467
|
+
function layoutMtime() {
|
|
468
|
+
try { return fs.statSync(layoutFile()).mtimeMs; } catch (_) { return 0; }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The current layout, re-read at most every 2s (like workspaces.js) and whenever the file changes. */
|
|
472
|
+
function loadRowLayout() {
|
|
473
|
+
const file = layoutFile();
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
if (cache.layout && cache.file === file && now - cache.at < 2000) return cache.layout;
|
|
476
|
+
const mtime = layoutMtime();
|
|
477
|
+
if (cache.layout && cache.file === file && cache.mtime === mtime) { cache.at = now; return cache.layout; }
|
|
478
|
+
let raw = {};
|
|
479
|
+
let error = '';
|
|
480
|
+
if (mtime) {
|
|
481
|
+
try { raw = yaml.load(fs.readFileSync(file, 'utf8')) || {}; } catch (err) { raw = {}; error = String(err.message || err).split('\n')[0]; }
|
|
482
|
+
if (!error && !withinBudget(raw)) { raw = {}; error = 'it is too large or too deeply nested to be a layout'; }
|
|
483
|
+
}
|
|
484
|
+
const layout = normalizeLayout(raw);
|
|
485
|
+
layout.error = error;
|
|
486
|
+
cache = { at: now, mtime, file, layout };
|
|
487
|
+
return layout;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function invalidateRowLayout() { cache = { at: 0, mtime: -1, file: '', layout: null }; }
|
|
491
|
+
|
|
492
|
+
function loadRowLayoutFresh() { invalidateRowLayout(); return loadRowLayout(); }
|
|
493
|
+
|
|
494
|
+
function refuseIfChanged(expectedMtime) {
|
|
495
|
+
if (expectedMtime === undefined || expectedMtime === null || String(expectedMtime) === '') return;
|
|
496
|
+
if (Number(expectedMtime) !== layoutMtime()) {
|
|
497
|
+
const err = new Error('row-actions.yml changed since this page was loaded');
|
|
498
|
+
err.code = 'CHANGED';
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const HEADER = '# Rows management for project rows (see core/workspace-app/row-layout.js).\n'
|
|
504
|
+
+ '# Written by Commands → Buttons; a hand edit works too. Delete this file to go back to the defaults.\n';
|
|
505
|
+
|
|
506
|
+
/** Write the layout. Refuses when the file changed since `expectedMtime` (the mtime the form was drawn with). */
|
|
507
|
+
function saveRowLayout(layout, expectedMtime) {
|
|
508
|
+
refuseIfChanged(expectedMtime);
|
|
509
|
+
const file = layoutFile();
|
|
510
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
511
|
+
fs.writeFileSync(tmp, HEADER + yaml.dump(toDoc(layout), { lineWidth: 120 }));
|
|
512
|
+
fs.renameSync(tmp, file);
|
|
513
|
+
invalidateRowLayout();
|
|
514
|
+
return layoutMtime();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function resetRowLayout(expectedMtime) {
|
|
518
|
+
refuseIfChanged(expectedMtime);
|
|
519
|
+
try { fs.unlinkSync(layoutFile()); } catch (_) { /* already the defaults */ }
|
|
520
|
+
invalidateRowLayout();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The file as text, or what it would be with the defaults. */
|
|
524
|
+
function definitionText() {
|
|
525
|
+
try { return fs.readFileSync(layoutFile(), 'utf8'); } catch (_) {
|
|
526
|
+
return HEADER + yaml.dump(toDoc(normalizeLayout({})), { lineWidth: 120 });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* What loading a pasted definition would do, like `rd jobs load`: `replace` takes the whole file; `skip` and
|
|
532
|
+
* `update` take only its actions, and do that to an id that already exists (Rundeck's duplicate option —
|
|
533
|
+
* skip is the default here, so loading the same file twice never doubles an action). Nothing is written.
|
|
534
|
+
*/
|
|
535
|
+
function checkDefinition(text, mode, current) {
|
|
536
|
+
const report = { errors: [], notes: [], next: null };
|
|
537
|
+
if (!String(text || '').trim()) {
|
|
538
|
+
report.errors.push('The definition is empty. To go back to the defaults, use Reset to the defaults below.');
|
|
539
|
+
return report;
|
|
540
|
+
}
|
|
541
|
+
let raw;
|
|
542
|
+
try { raw = yaml.load(String(text || '')); } catch (err) {
|
|
543
|
+
report.errors.push(`The YAML does not parse: ${String(err.message || err).split('\n')[0]}`);
|
|
544
|
+
return report;
|
|
545
|
+
}
|
|
546
|
+
if (raw === undefined || raw === null) raw = {};
|
|
547
|
+
if (!withinBudget(raw)) {
|
|
548
|
+
report.errors.push('The definition is too large or too deeply nested to be a layout (YAML aliases can make a few lines stand for billions of values).');
|
|
549
|
+
return report;
|
|
550
|
+
}
|
|
551
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
552
|
+
report.errors.push('The top level is a mapping with rows, hidden, menus and custom.');
|
|
553
|
+
return report;
|
|
554
|
+
}
|
|
555
|
+
if (raw.custom !== undefined && !Array.isArray(raw.custom)) {
|
|
556
|
+
report.errors.push('custom is a list of actions.');
|
|
557
|
+
return report;
|
|
558
|
+
}
|
|
559
|
+
const incoming = (raw.custom || []).map((c, i) => ({ i, e: cleanCustom(c) }));
|
|
560
|
+
const seenIds = new Set();
|
|
561
|
+
for (const { i, e } of incoming) {
|
|
562
|
+
const why = customRefusal(e);
|
|
563
|
+
if (why) report.errors.push(`Action ${i + 1}${e && e.id ? ` (${e.id})` : ''}: ${why}.`);
|
|
564
|
+
else if (seenIds.has(e.id)) report.errors.push(`Action ${i + 1}: the id ${e.id} is used twice.`);
|
|
565
|
+
else seenIds.add(e.id);
|
|
566
|
+
}
|
|
567
|
+
for (const { i, e } of incoming) {
|
|
568
|
+
const why = report.errors.length ? '' : useRefusal(e);
|
|
569
|
+
if (why) report.errors.push(`Action ${i + 1} (${e.id}): ${why}.`);
|
|
570
|
+
}
|
|
571
|
+
if (report.errors.length) return report;
|
|
572
|
+
|
|
573
|
+
const actions = incoming.map((x) => x.e);
|
|
574
|
+
if (mode === 'skip' || mode === 'update') {
|
|
575
|
+
const doc = toDoc(current);
|
|
576
|
+
let added = 0; let updated = 0; let skipped = 0;
|
|
577
|
+
for (const e of actions) {
|
|
578
|
+
const at = doc.custom.findIndex((c) => c.id === e.id);
|
|
579
|
+
if (at < 0) { doc.custom.push(customToDoc(e)); added += 1; } else if (mode === 'update') { doc.custom[at] = customToDoc(e); updated += 1; } else skipped += 1;
|
|
580
|
+
}
|
|
581
|
+
report.notes.push(`${added} action(s) added, ${updated} updated, ${skipped} skipped because the id already exists.`);
|
|
582
|
+
if (raw.rows || raw.lines || raw.hidden || raw.menus) report.notes.push('Its rows, hidden buttons and menus are not loaded in this mode — choose "replace" for those.');
|
|
583
|
+
report.next = normalizeLayout(doc);
|
|
584
|
+
} else {
|
|
585
|
+
const next = normalizeLayout(raw);
|
|
586
|
+
const known = new Set(knownButtonIds(next));
|
|
587
|
+
const named = [...(Array.isArray(raw.rows) ? raw.rows : (Array.isArray(raw.lines) ? raw.lines : [])).flat(), ...(Array.isArray(raw.hidden) ? raw.hidden : [])]
|
|
588
|
+
.filter((x) => typeof x === 'string' || typeof x === 'number').map(String);
|
|
589
|
+
const unknown = [...new Set(named.filter((id) => !known.has(id)))];
|
|
590
|
+
if (unknown.length) report.notes.push(`Not a button, so ignored: ${unknown.join(', ')}.`);
|
|
591
|
+
if ((Array.isArray(raw.rows) ? raw.rows : []).length > MAX_ROWS) report.notes.push(`Only the first ${MAX_ROWS} rows are kept.`);
|
|
592
|
+
report.notes.push(`${next.rows.length} row(s), ${next.hidden.length} hidden button(s), ${next.custom.length} action(s).`);
|
|
593
|
+
report.next = next;
|
|
594
|
+
}
|
|
595
|
+
return report;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------- what has been seen
|
|
599
|
+
|
|
600
|
+
const seen = { data: null, timer: null };
|
|
601
|
+
|
|
602
|
+
function loadSeen() {
|
|
603
|
+
if (!seen.data) {
|
|
604
|
+
try { seen.data = JSON.parse(fs.readFileSync(seenFile(), 'utf8')) || {}; } catch (_) { seen.data = {}; }
|
|
605
|
+
}
|
|
606
|
+
return seen.data;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function flushSeenSoon() {
|
|
610
|
+
if (seen.timer) return;
|
|
611
|
+
seen.timer = setTimeout(() => {
|
|
612
|
+
seen.timer = null;
|
|
613
|
+
try { fs.writeFileSync(seenFile(), JSON.stringify(seen.data, null, 1)); } catch (_) { /* the page lists less, nothing breaks */ }
|
|
614
|
+
}, 1500);
|
|
615
|
+
if (seen.timer.unref) seen.timer.unref();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Bounded: a SCREENS menu names every session it has ever listed, and that must not grow for ever.
|
|
619
|
+
const MAX_GROUPS = 60;
|
|
620
|
+
const MAX_ENTRIES = 80;
|
|
621
|
+
|
|
622
|
+
function recordSeen(menuId, groups) {
|
|
623
|
+
const all = loadSeen();
|
|
624
|
+
const menu = all[menuId] || (all[menuId] = { groups: {} });
|
|
625
|
+
let dirty = false;
|
|
626
|
+
for (const g of groups) {
|
|
627
|
+
if (!g.key) continue;
|
|
628
|
+
let rec = menu.groups[g.key];
|
|
629
|
+
// After the first page load almost every entry is already on record: its text is only read when it is new.
|
|
630
|
+
// Reading it for every entry of every menu of every row was most of what arranging a page cost.
|
|
631
|
+
if (!rec) {
|
|
632
|
+
if (Object.keys(menu.groups).length >= MAX_GROUPS) continue;
|
|
633
|
+
// A group named by data-group has a header that changes from row to row (a project name, a server);
|
|
634
|
+
// it is listed by that name, not by whichever project happened to show it first.
|
|
635
|
+
const named = g.header && /\bdata-group="/.test(g.header) && lc(plainText(g.header)) !== g.key;
|
|
636
|
+
rec = { label: named ? g.key.replace(/^./, (c) => c.toUpperCase()) : plainText(g.header || g.entries[0] || ''), entries: {} };
|
|
637
|
+
menu.groups[g.key] = rec;
|
|
638
|
+
dirty = true;
|
|
639
|
+
}
|
|
640
|
+
let count = Object.keys(rec.entries).length;
|
|
641
|
+
g.entries.forEach((li, i) => {
|
|
642
|
+
const k = g.keys ? g.keys[i] : entryKey(li);
|
|
643
|
+
if (!k || rec.entries[k] || count >= MAX_ENTRIES) return;
|
|
644
|
+
const t = plainText(li).slice(0, 120);
|
|
645
|
+
if (!t) return;
|
|
646
|
+
rec.entries[k] = t;
|
|
647
|
+
count += 1;
|
|
648
|
+
dirty = true;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (dirty) flushSeenSoon();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function seenMenu(menuId) {
|
|
655
|
+
const m = loadSeen()[menuId];
|
|
656
|
+
return { groups: (m && m.groups) || {} };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------- run history
|
|
660
|
+
|
|
661
|
+
function recordRun(run) {
|
|
662
|
+
const file = historyFile();
|
|
663
|
+
try {
|
|
664
|
+
fs.appendFileSync(file, `${JSON.stringify(run)}\n`);
|
|
665
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
666
|
+
if (lines.length > 400) fs.writeFileSync(file, `${lines.slice(-300).join('\n')}\n`);
|
|
667
|
+
} catch (_) { /* the Activity tab shows less, nothing breaks */ }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function readRuns() {
|
|
671
|
+
try {
|
|
672
|
+
return fs.readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean)
|
|
673
|
+
.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
|
|
674
|
+
} catch (_) { return []; }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sessionLog(session) {
|
|
678
|
+
try {
|
|
679
|
+
const file = path.join(ROOT, '.screens', `${session}.log`);
|
|
680
|
+
const size = fs.statSync(file).size;
|
|
681
|
+
const len = Math.min(size, 512 * 1024);
|
|
682
|
+
const fd = fs.openSync(file, 'r');
|
|
683
|
+
try { const buf = Buffer.alloc(len); fs.readSync(fd, buf, 0, len, size - len); return buf.toString('utf8'); } finally { fs.closeSync(fd); }
|
|
684
|
+
} catch (_) { return ''; }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function liveSessions() {
|
|
688
|
+
try {
|
|
689
|
+
const j = JSON.parse(fs.readFileSync(path.join(ROOT, '.screens', 'host-sessions.json'), 'utf8'));
|
|
690
|
+
return new Set((j.sessions || []).map((s) => s.session));
|
|
691
|
+
} catch (_) { return new Set(); }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Before a session's log is truncated by the next run of the same action, write down how the last run
|
|
696
|
+
* ended — otherwise its line in Activity would start describing the new run.
|
|
697
|
+
*/
|
|
698
|
+
function settleRun(session) {
|
|
699
|
+
const runs = readRuns();
|
|
700
|
+
let i = runs.length - 1;
|
|
701
|
+
while (i >= 0 && runs[i].session !== session) i -= 1;
|
|
702
|
+
if (i < 0 || runs[i].status) return;
|
|
703
|
+
// Only a run whose output already says how it ended. A request that is refused because the last run is still
|
|
704
|
+
// going comes through here too, and used to write that run down as "unknown" for good, mid-run.
|
|
705
|
+
const text = sessionLog(session);
|
|
706
|
+
if (!/ROW-ACTION (succeeded|failed)/.test(text)) return;
|
|
707
|
+
runs[i].status = runStatus(text, false);
|
|
708
|
+
try { fs.writeFileSync(historyFile(), `${runs.map((r) => JSON.stringify(r)).join('\n')}\n`); } catch (_) { /* best effort */ }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------------------------------------------------------------- rearranging a menu
|
|
712
|
+
|
|
713
|
+
function plainText(html) {
|
|
714
|
+
return String(html)
|
|
715
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
716
|
+
.replace(/<[^>]*>/g, ' ')
|
|
717
|
+
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
718
|
+
.replace(/&/g, '&')
|
|
719
|
+
.replace(/\s+/g, ' ')
|
|
720
|
+
.trim();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const keyText = (s) => lc(s).replace(/[<>]/g, '').replace(/\s*\(running(…|\.\.\.)\)\s*$/, '').trim().slice(0, 120);
|
|
724
|
+
|
|
725
|
+
function entryKey(liHtml) {
|
|
726
|
+
const m = /\bdata-entry="([^"]+)"/.exec(liHtml);
|
|
727
|
+
return m ? keyText(m[1]) : keyText(plainText(liHtml));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function groupKeyOf(headerHtml) {
|
|
731
|
+
const m = /\bdata-group="([^"]+)"/.exec(headerHtml);
|
|
732
|
+
return m ? keyText(m[1]) : keyText(plainText(headerHtml).split(' · ')[0]);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The top-level <li> of a menu, or null when the markup has anything else in it that could be lost. */
|
|
736
|
+
function splitTopLevelLi(inner) {
|
|
737
|
+
const src = String(inner).replace(/<!--[\s\S]*?-->/g, '');
|
|
738
|
+
const out = [];
|
|
739
|
+
const re = /<li\b[^>]*>|<\/li>/gi;
|
|
740
|
+
let depth = 0;
|
|
741
|
+
let start = -1;
|
|
742
|
+
let last = 0;
|
|
743
|
+
let stray = '';
|
|
744
|
+
let m;
|
|
745
|
+
while ((m = re.exec(src))) {
|
|
746
|
+
if (m[0][1] !== '/') {
|
|
747
|
+
if (depth === 0) { stray += src.slice(last, m.index); start = m.index; }
|
|
748
|
+
depth += 1;
|
|
749
|
+
} else if (depth > 0) {
|
|
750
|
+
depth -= 1;
|
|
751
|
+
if (depth === 0) { out.push(src.slice(start, re.lastIndex)); last = re.lastIndex; }
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
stray += depth === 0 ? src.slice(last) : 'unclosed';
|
|
755
|
+
return stray.trim() ? null : out;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const isDivider = (li) => /^<li\b[^>]*\bclass="[^"]*\buk-nav-divider\b/i.test(li);
|
|
759
|
+
const isHeader = (li) => /^<li\b[^>]*\bclass="[^"]*\buk-nav-header\b/i.test(li);
|
|
760
|
+
|
|
761
|
+
function customEntryHtml(c, ctx) {
|
|
762
|
+
const vals = `hx-vals='{"workspace":"${esc(ctx.key || '')}","projectName":"${esc(ctx.project || '')}","id":"${esc(c.id)}"}'`;
|
|
763
|
+
const title = esc(c.description || c.steps.map(stepText).join(' → '));
|
|
764
|
+
const icon = '<span uk-icon="icon: play-circle; ratio: .7"></span>';
|
|
765
|
+
if (effectiveOptions(c).length) {
|
|
766
|
+
// Rundeck asks for a job's options before it runs; so does this, in the dashboard's one modal.
|
|
767
|
+
return `<li><a href hx-post="/actions/row-custom-options" ${vals} hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
768
|
+
hx-on::after:request="UIkit.modal('#editor-modal').show()" data-entry="custom-${esc(c.id)}" title="${title}">${icon} ${esc(c.label)}…</a></li>`;
|
|
769
|
+
}
|
|
770
|
+
const arm = c.confirm ? ' class="arm-step" hx-trigger="confirmed-remove"' : '';
|
|
771
|
+
return `<li><a href hx-post="/actions/row-custom-run" ${vals} hx-target="#webship-workspace-output" hx-swap="innerHTML"${arm} data-entry="custom-${esc(c.id)}" title="${title}">${icon} ${esc(c.label)}</a></li>`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* One menu's list, rearranged: groups in the saved order (the rest after, as they were), hidden groups
|
|
776
|
+
* and entries left out, and this button's actions added to their group.
|
|
777
|
+
*/
|
|
778
|
+
function arrangeNavInner(menuId, inner, ctx = {}) {
|
|
779
|
+
const layout = ctx.layout || loadRowLayout();
|
|
780
|
+
const lis = splitTopLevelLi(inner);
|
|
781
|
+
if (!lis) return inner;
|
|
782
|
+
|
|
783
|
+
const groups = [];
|
|
784
|
+
let cur = null;
|
|
785
|
+
for (const li of lis) {
|
|
786
|
+
if (isDivider(li)) { cur = null; continue; }
|
|
787
|
+
if (isHeader(li)) { cur = { header: li, entries: [] }; groups.push(cur); continue; }
|
|
788
|
+
if (!cur) { cur = { header: null, entries: [] }; groups.push(cur); }
|
|
789
|
+
cur.entries.push(li);
|
|
790
|
+
}
|
|
791
|
+
for (const g of groups) {
|
|
792
|
+
g.keys = g.entries.map(entryKey);
|
|
793
|
+
g.key = g.header ? groupKeyOf(g.header) : (g.keys[0] || '');
|
|
794
|
+
g.hadEntries = g.entries.length > 0;
|
|
795
|
+
}
|
|
796
|
+
recordSeen(menuId, groups);
|
|
797
|
+
|
|
798
|
+
// Nothing saved for this menu and no action of yours in it: it is shown exactly as it was rendered.
|
|
799
|
+
if (!layout.menus[menuId] && (ctx.withCustom === false || !layout.custom.some((c) => c.button === menuId))) return inner;
|
|
800
|
+
|
|
801
|
+
const m = layout.menus[menuId] || { groups: [], hiddenGroups: [], hiddenEntries: [], order: {}, columns: [] };
|
|
802
|
+
const hideGroups = new Set(m.hiddenGroups);
|
|
803
|
+
const hideEntries = new Set(m.hiddenEntries);
|
|
804
|
+
let kept = groups
|
|
805
|
+
.filter((g) => !hideGroups.has(g.key))
|
|
806
|
+
.map((g) => {
|
|
807
|
+
const keep = g.entries.map((li, i) => !hideEntries.has(g.keys[i]));
|
|
808
|
+
return { ...g, entries: g.entries.filter((_, i) => keep[i]), keys: g.keys.filter((_, i) => keep[i]) };
|
|
809
|
+
})
|
|
810
|
+
// A group whose every entry is hidden goes with them; a header that never had entries (a note) stays.
|
|
811
|
+
.filter((g) => g.entries.length || (g.header && !g.hadEntries));
|
|
812
|
+
|
|
813
|
+
if (ctx.withCustom !== false) {
|
|
814
|
+
for (const c of layout.custom) {
|
|
815
|
+
if (c.button !== menuId || !whenMatches(c.when, ctx)) continue;
|
|
816
|
+
const gk = keyText(c.group);
|
|
817
|
+
if (hideGroups.has(gk) || hideEntries.has(`custom-${c.id}`)) continue;
|
|
818
|
+
let g = kept.find((x) => x.key === gk);
|
|
819
|
+
if (!g) {
|
|
820
|
+
g = { key: gk, header: `<li class="uk-nav-header" data-group="${esc(gk)}">${esc(c.group)}</li>`, entries: [], keys: [], hadEntries: true };
|
|
821
|
+
kept.push(g);
|
|
822
|
+
}
|
|
823
|
+
g.entries.push(customEntryHtml(c, ctx));
|
|
824
|
+
g.keys.push(`custom-${c.id}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// The saved order of the entries inside each group — the dashboard's commands and your actions alike.
|
|
829
|
+
for (const g of kept) {
|
|
830
|
+
const o = (m.order || {})[g.key];
|
|
831
|
+
if (!o || !o.length) continue;
|
|
832
|
+
const pos = (k) => { const i = o.indexOf(k); return i < 0 ? o.length : i; };
|
|
833
|
+
const sorted = g.entries.map((li, i) => ({ li, k: g.keys[i], i })).sort((a, b) => pos(a.k) - pos(b.k) || a.i - b.i);
|
|
834
|
+
g.entries = sorted.map((x) => x.li);
|
|
835
|
+
g.keys = sorted.map((x) => x.k);
|
|
836
|
+
}
|
|
837
|
+
const rank = (g) => { const i = m.groups.indexOf(g.key); return i < 0 ? m.groups.length : i; };
|
|
838
|
+
kept = kept.map((g, i) => ({ g, i })).sort((a, b) => rank(a.g) - rank(b.g) || a.i - b.i).map((x) => x.g);
|
|
839
|
+
return kept.map((g) => `${g.header || ''}${g.entries.join('')}`).join('<li class="uk-nav-divider"></li>');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function matchingClose(html, from, tag) {
|
|
843
|
+
const re = new RegExp(`<${tag}\\b[^>]*>|</${tag}>`, 'gi');
|
|
844
|
+
re.lastIndex = from;
|
|
845
|
+
let depth = 1;
|
|
846
|
+
let m;
|
|
847
|
+
while ((m = re.exec(html))) {
|
|
848
|
+
if (m[0][1] === '/') { depth -= 1; if (!depth) return m.index; } else depth += 1;
|
|
849
|
+
}
|
|
850
|
+
return -1;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Every menu list inside one button, rearranged. Actions go into the first list with something to click. */
|
|
854
|
+
function arrangeButtonHtml(menuId, html, ctx = {}) {
|
|
855
|
+
if (!html) return html;
|
|
856
|
+
const openRe = /<ul\b[^>]*\bclass="[^"]*\buk-dropdown-nav\b[^"]*"[^>]*>/gi;
|
|
857
|
+
const navs = [];
|
|
858
|
+
let m;
|
|
859
|
+
while ((m = openRe.exec(html))) {
|
|
860
|
+
const start = openRe.lastIndex;
|
|
861
|
+
const close = matchingClose(html, start, 'ul');
|
|
862
|
+
if (close < 0) return html;
|
|
863
|
+
navs.push([start, close]);
|
|
864
|
+
openRe.lastIndex = close;
|
|
865
|
+
}
|
|
866
|
+
if (!navs.length) return html;
|
|
867
|
+
const withLinks = navs.findIndex(([s, e]) => /<a\b/.test(html.slice(s, e)));
|
|
868
|
+
const target = withLinks < 0 ? 0 : withLinks;
|
|
869
|
+
let out = '';
|
|
870
|
+
let pos = 0;
|
|
871
|
+
navs.forEach(([s, e], i) => {
|
|
872
|
+
out += html.slice(pos, s) + arrangeNavInner(menuId, html.slice(s, e), { ...ctx, withCustom: i === target });
|
|
873
|
+
pos = e;
|
|
874
|
+
});
|
|
875
|
+
return out + html.slice(pos);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* A menu set to more than one column becomes that many lists side by side, each group in the column it was
|
|
880
|
+
* put in (a group not placed yet goes to the last). Only a button with a single menu list that no request
|
|
881
|
+
* fills in later is split; anything else is returned as it was.
|
|
882
|
+
*/
|
|
883
|
+
function columnizeButtonHtml(menuId, html, layout) {
|
|
884
|
+
const m = layout.menus[menuId];
|
|
885
|
+
if (!html || !m || !m.columns || m.columns.length < 2 || !takesColumns(menuId)) return html;
|
|
886
|
+
const found = [...html.matchAll(/<ul\b[^>]*\bclass="[^"]*\buk-dropdown-nav\b[^"]*"[^>]*>/gi)];
|
|
887
|
+
if (found.length !== 1 || /\bhx-(get|post)=/.test(found[0][0])) return html;
|
|
888
|
+
const openTag = found[0][0];
|
|
889
|
+
const start = found[0].index + openTag.length;
|
|
890
|
+
const close = matchingClose(html, start, 'ul');
|
|
891
|
+
const lis = close < 0 ? null : splitTopLevelLi(html.slice(start, close));
|
|
892
|
+
if (!lis) return html;
|
|
893
|
+
const cols = m.columns.map(() => []);
|
|
894
|
+
let cur = null;
|
|
895
|
+
for (const li of lis) {
|
|
896
|
+
if (isDivider(li)) { cur = null; continue; }
|
|
897
|
+
if (isHeader(li) || !cur) {
|
|
898
|
+
cur = { header: isHeader(li) ? li : null, entries: [] };
|
|
899
|
+
const key = cur.header ? groupKeyOf(li) : entryKey(li);
|
|
900
|
+
const at = m.columns.findIndex((c) => c.includes(key));
|
|
901
|
+
cols[at < 0 ? cols.length - 1 : at].push(cur);
|
|
902
|
+
if (cur.header) continue;
|
|
903
|
+
}
|
|
904
|
+
cur.entries.push(li);
|
|
905
|
+
}
|
|
906
|
+
// One list per column; DDEV's own CSS columns would split each of them again, so that class goes.
|
|
907
|
+
const tag = openTag.replace(/\s*\bddev-nav-columns\b/, '');
|
|
908
|
+
const lists = cols.filter((c) => c.length)
|
|
909
|
+
.map((c) => `${tag}${c.map((g) => `${g.header || ''}${g.entries.join('')}`).join('<li class="uk-nav-divider"></li>')}</ul>`);
|
|
910
|
+
if (lists.length < 2) return html;
|
|
911
|
+
return `${html.slice(0, found[0].index)}<div class="menu-columns menu-columns-${lists.length}">${lists.join('')}</div>${html.slice(close + 5)}`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** A button someone made by naming it on an action. Empty on a row where none of its actions apply. */
|
|
915
|
+
function customButtonHtml(id, ctx = {}) {
|
|
916
|
+
const layout = ctx.layout || loadRowLayout();
|
|
917
|
+
const n = layout.custom.filter((c) => c.button === id && whenMatches(c.when, ctx)).length;
|
|
918
|
+
if (!n) return '';
|
|
919
|
+
return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-custom act-menu-${esc(id)}">
|
|
920
|
+
<button class="uk-button uk-button-default uk-button-small" type="button" title="Your own actions — Commands → Buttons"><span uk-icon="icon: bolt; ratio: .7"></span> ${esc(buttonLabel(id))} <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
|
|
921
|
+
<div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav"></ul></div>
|
|
922
|
+
</div><span class="act-status uk-text-meta">${n} action${n === 1 ? '' : 's'}</span></span>`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The row's rows of buttons. `buttons` maps each built-in id to its rendered HTML ('' when it does not
|
|
927
|
+
* apply to this row). `preArranged` names buttons whose menu was already rearranged, because they count
|
|
928
|
+
* what is in it for their caption.
|
|
929
|
+
*/
|
|
930
|
+
function rowLinesHtml(buttons, ctx = {}, { preArranged = new Set() } = {}) {
|
|
931
|
+
const layout = ctx.layout || loadRowLayout();
|
|
932
|
+
const full = { ...ctx, layout };
|
|
933
|
+
const hidden = new Set(layout.hidden);
|
|
934
|
+
const render = (id) => {
|
|
935
|
+
if (hidden.has(id)) return '';
|
|
936
|
+
const html = Object.prototype.hasOwnProperty.call(buttons, id) ? (buttons[id] || '')
|
|
937
|
+
: (BUILTIN_IDS.has(id) ? '' : customButtonHtml(id, full));
|
|
938
|
+
if (!html) return '';
|
|
939
|
+
const placed = columnizeButtonHtml(id, preArranged.has(id) ? html : arrangeButtonHtml(id, html, full), layout);
|
|
940
|
+
// Icon alone or label alone: the button is WRAPPED, never rewritten. Its markup is built by whoever
|
|
941
|
+
// added it — a built-in in server.js, a custom one here — and reaching into that string to delete a
|
|
942
|
+
// span is the documented way this breaks. Both halves stay, so a screen reader still reads the name.
|
|
943
|
+
const how = (layout.show || {})[id];
|
|
944
|
+
return (how === 'icon' || how === 'text') ? `<span class="row-show-${how}">${placed}</span>` : placed;
|
|
945
|
+
};
|
|
946
|
+
const rows = layout.rows.map((ids) => ids.map(render).join(''));
|
|
947
|
+
return { rows, first: rows[0] || '', second: rows[1] || '', extra: rows.slice(2) };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** The form a run with options starts from: every option, its default, and what it may be. */
|
|
951
|
+
function actionOptionsFormHtml(entry, { workspace, project }) {
|
|
952
|
+
const hidden = `<input type="hidden" name="workspace" value="${esc(workspace)}"><input type="hidden" name="projectName" value="${esc(project)}">`
|
|
953
|
+
+ `<input type="hidden" name="id" value="${esc(entry.id)}"><input type="hidden" name="submitted" value="1">`;
|
|
954
|
+
const fields = effectiveOptions(entry).map((o) => {
|
|
955
|
+
const name = `opt_${o.name}`;
|
|
956
|
+
const label = `${esc(o.label || o.name)}${o.required ? ' <span class="uk-text-danger">*</span>' : ''}`;
|
|
957
|
+
const hint = [o.regex ? `matches <code>${esc(o.regex)}</code>` : '', o.values.length && !o.enforced ? 'or type your own' : ''].filter(Boolean).join(' · ');
|
|
958
|
+
let input;
|
|
959
|
+
if (o.values.length && o.enforced) {
|
|
960
|
+
input = `<select class="uk-select" name="${name}">${o.values.map((v) => `<option${v === o.value ? ' selected' : ''}>${esc(v)}</option>`).join('')}</select>`;
|
|
961
|
+
} else {
|
|
962
|
+
const list = o.values.length ? ` list="row-opt-${esc(entry.id)}-${esc(o.name)}"` : '';
|
|
963
|
+
input = `<input class="uk-input" name="${name}" value="${esc(o.value)}"${list} autocomplete="off" spellcheck="false">`
|
|
964
|
+
+ (o.values.length ? `<datalist id="row-opt-${esc(entry.id)}-${esc(o.name)}">${o.values.map((v) => `<option value="${esc(v)}">`).join('')}</datalist>` : '');
|
|
965
|
+
}
|
|
966
|
+
return `<label class="row-option">${label}${input}${hint ? `<span class="uk-text-meta">${hint}</span>` : ''}</label>`;
|
|
967
|
+
}).join('');
|
|
968
|
+
const done = `hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-on::after:request="if (event.detail.successful) UIkit.modal('#editor-modal').hide()"`;
|
|
969
|
+
const run = entry.confirm
|
|
970
|
+
? `<button type="button" class="uk-button uk-button-danger arm-step" hx-post="/actions/row-custom-run" hx-include="closest form" hx-trigger="confirmed-remove" ${done}>Run ${esc(entry.label)}</button>`
|
|
971
|
+
: `<button type="submit" class="uk-button uk-button-primary">Run ${esc(entry.label)}</button>`;
|
|
972
|
+
return `<div class="uk-card uk-card-default uk-card-body editor-card row-options">
|
|
973
|
+
<h3 class="uk-margin-small-bottom">${esc(entry.label)}</h3>
|
|
974
|
+
<p class="uk-text-meta">On <strong>${esc(project)}</strong>${entry.description ? ` — ${esc(entry.description)}` : ''}</p>
|
|
975
|
+
<form ${entry.confirm ? 'hx-on:submit="event.preventDefault()"' : `hx-post="/actions/row-custom-run" ${done}`}>
|
|
976
|
+
${hidden}${fields}
|
|
977
|
+
<p class="uk-text-meta">Runs ${entry.steps.length} step${entry.steps.length === 1 ? '' : 's'}: ${entry.steps.map((s) => `<code>${esc(stepText(s))}</code>`).join(' → ')}</p>
|
|
978
|
+
${run}
|
|
979
|
+
</form>
|
|
980
|
+
</div>`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ---------------------------------------------------------------- Commands → Buttons
|
|
984
|
+
|
|
985
|
+
function moveArrows(what) {
|
|
986
|
+
return `<span class="ws-move">
|
|
987
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" title="Move ${esc(what)} up"
|
|
988
|
+
hx-on:click="const li = this.closest('li'); if (li.previousElementSibling) li.parentNode.insertBefore(li, li.previousElementSibling)"><span uk-icon="icon: chevron-up; ratio: .7"></span></button>
|
|
989
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" title="Move ${esc(what)} down"
|
|
990
|
+
hx-on:click="const li = this.closest('li'); if (li.nextElementSibling) li.parentNode.insertBefore(li.nextElementSibling, li)"><span uk-icon="icon: chevron-down; ratio: .7"></span></button>
|
|
991
|
+
</span>`;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const EXAMPLE = `custom:
|
|
995
|
+
- id: update-db
|
|
996
|
+
label: Update the database
|
|
997
|
+
button: ddev
|
|
998
|
+
group: drupal
|
|
999
|
+
description: Switch branch, install, run the updates
|
|
1000
|
+
when: [ddev, composer, "!workspace:products"]
|
|
1001
|
+
confirm: true
|
|
1002
|
+
keepgoing: false
|
|
1003
|
+
options:
|
|
1004
|
+
- name: branch
|
|
1005
|
+
label: Branch
|
|
1006
|
+
value: main
|
|
1007
|
+
regex: '^[A-Za-z0-9._/-]+$'
|
|
1008
|
+
required: true
|
|
1009
|
+
steps:
|
|
1010
|
+
- git fetch origin {{opt.branch}}
|
|
1011
|
+
- git switch {{opt.branch}}
|
|
1012
|
+
- ddev composer install
|
|
1013
|
+
- command: ddev drush updb -y
|
|
1014
|
+
on_error: ddev drush watchdog:show --count=20`;
|
|
1015
|
+
|
|
1016
|
+
function addActionFields(key, groupsList, { withButton = false } = {}) {
|
|
1017
|
+
const f = (n) => `add_${n}_${esc(key)}`;
|
|
1018
|
+
return `<div class="row-add-grid">
|
|
1019
|
+
${commandPickerHtml(f('use'))}
|
|
1020
|
+
${withButton ? `<label>Button name <input class="uk-input uk-form-small" name="${f('button')}" maxlength="30" placeholder="tools"></label>` : ''}
|
|
1021
|
+
<label>Label <input class="uk-input uk-form-small" name="${f('label')}" maxlength="60" placeholder="Rebuild caches"></label>
|
|
1022
|
+
<label>Group <input class="uk-input uk-form-small" name="${f('group')}" maxlength="40" placeholder="Custom"${groupsList ? ` list="row-groups-${esc(key)}"` : ''}></label>
|
|
1023
|
+
${groupsList ? `<datalist id="row-groups-${esc(key)}">${groupsList}</datalist>` : ''}
|
|
1024
|
+
<label>Shows on <select class="uk-select uk-form-small" name="${f('when')}">${Object.entries(WHENS).map(([k, t]) => `<option value="${k}">${esc(t)}</option>`).join('')}</select></label>
|
|
1025
|
+
<label class="row-add-steps">Commands, one per line <span class="uk-text-meta">— or use: <command id></span> <textarea class="uk-textarea uk-form-small" name="${f('steps')}" rows="2" placeholder="ddev drush cr" spellcheck="false" data-ace="sh"></textarea></label>
|
|
1026
|
+
<label class="row-add-check"><input class="uk-checkbox" type="checkbox" name="${f('keepgoing')}" value="1"> keep going when a step fails</label>
|
|
1027
|
+
<label class="row-add-check"><input class="uk-checkbox" type="checkbox" name="${f('confirm')}" value="1"> ask before running</label>
|
|
1028
|
+
<label>Opens <select class="uk-select uk-form-small" name="${f('open')}"><option value="toast">a toast linking to it</option><option value="screen">its screen, right away</option></select></label>
|
|
1029
|
+
</div>`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Add a row, and add a button: the dashboard's one modal rather than controls at the bottom of a long form.
|
|
1034
|
+
*
|
|
1035
|
+
* Both post the rows form with them (`#row-arrange-form`), because a drag that has not been saved yet is part of
|
|
1036
|
+
* what the layout should become — the inline versions got that for free by sitting inside the form, and a modal
|
|
1037
|
+
* sits outside it. Both close the modal when the write succeeds; the editor behind it is replaced by the answer.
|
|
1038
|
+
*/
|
|
1039
|
+
function addRowFormHtml() {
|
|
1040
|
+
const layout = loadRowLayoutFresh();
|
|
1041
|
+
const full = layout.rows.length >= MAX_ROWS;
|
|
1042
|
+
return `<div class="editor-card">
|
|
1043
|
+
<h3 class="uk-margin-small-bottom">Add a row</h3>
|
|
1044
|
+
<p class="uk-text-meta">A project row is built from rows of buttons, top to bottom. A new one is added at the
|
|
1045
|
+
bottom, empty — drag buttons into it, or pick it from a button's row selector. There ${layout.rows.length === 1
|
|
1046
|
+
? 'is 1 row' : `are ${layout.rows.length} rows`} now, and up to ${MAX_ROWS} are allowed.</p>
|
|
1047
|
+
${full ? `<p class="msg error">All ${MAX_ROWS} rows are in use. Remove one first.</p>` : `
|
|
1048
|
+
<button type="button" class="uk-button uk-button-primary" hx-post="/actions/row-rows"
|
|
1049
|
+
hx-vals='{"op":"add","pane":"0","mtime":"${layoutMtime()}"}' hx-include="#row-arrange-form"
|
|
1050
|
+
hx-target="#row-actions-editor" hx-swap="outerHTML"><span uk-icon="icon: plus; ratio: .8"></span> Add the row</button>`}
|
|
1051
|
+
</div>`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function addButtonFormHtml() {
|
|
1055
|
+
const mtime = layoutMtime();
|
|
1056
|
+
return `<div class="editor-card">
|
|
1057
|
+
<h3 class="uk-margin-small-bottom">Add a button</h3>
|
|
1058
|
+
<p class="uk-text-meta">A button of your own on every project row, made by its first action: name the button, then
|
|
1059
|
+
say what the action does. More actions go in the button itself, once it is there.</p>
|
|
1060
|
+
<div class="row-add-action">
|
|
1061
|
+
${addActionFields('new', '', { withButton: true })}
|
|
1062
|
+
<button type="button" class="uk-button uk-button-primary" hx-post="/actions/row-custom-add"
|
|
1063
|
+
hx-include="#row-arrange-form, closest .row-add-action"
|
|
1064
|
+
hx-vals='{"for_button":"__new__","mtime":"${mtime}","pane":"0"}'
|
|
1065
|
+
hx-target="#row-actions-editor" hx-swap="outerHTML"><span uk-icon="icon: plus; ratio: .8"></span> Add the button</button>
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function rowActionsEditorHtml(message = '', pane = 0, { openButton = '' } = {}) {
|
|
1071
|
+
const layout = loadRowLayoutFresh();
|
|
1072
|
+
const mtime = layoutMtime();
|
|
1073
|
+
const mt = `<input type="hidden" name="mtime" value="${mtime}">`;
|
|
1074
|
+
const target = 'hx-target="#row-actions-editor" hx-swap="outerHTML"';
|
|
1075
|
+
const whereOptions = (where) => [...layout.rows.map((_, i) => [String(i), `Row ${i + 1}`]), ['hidden', 'Hidden']]
|
|
1076
|
+
.map(([v, t]) => `<option value="${v}"${String(where) === v ? ' selected' : ''}>${t}</option>`).join('');
|
|
1077
|
+
|
|
1078
|
+
const buttonCard = (id, where) => {
|
|
1079
|
+
const label = buttonLabel(id);
|
|
1080
|
+
const shownAs = (layout.show || {})[id] || '';
|
|
1081
|
+
const cat = seenMenu(id).groups;
|
|
1082
|
+
const keys = Object.keys(cat);
|
|
1083
|
+
const m = layout.menus[id] || { groups: [], hiddenGroups: [], hiddenEntries: [], order: {}, columns: [] };
|
|
1084
|
+
const hg = new Set(m.hiddenGroups);
|
|
1085
|
+
const he = new Set(m.hiddenEntries);
|
|
1086
|
+
const mine = layout.custom.filter((c) => c.button === id);
|
|
1087
|
+
const nHidden = keys.filter((k) => hg.has(k)).length
|
|
1088
|
+
+ keys.reduce((n, k) => n + Object.keys(cat[k].entries).filter((e) => he.has(e)).length, 0);
|
|
1089
|
+
const bits = [
|
|
1090
|
+
hasMenu(id) ? (keys.length ? `${keys.length} group${keys.length === 1 ? '' : 's'}` : 'not seen yet') : 'a single button',
|
|
1091
|
+
nHidden ? `${nHidden} hidden` : '',
|
|
1092
|
+
mine.length ? `${mine.length} of yours` : '',
|
|
1093
|
+
].filter(Boolean).join(' · ');
|
|
1094
|
+
// What the menu holds: the groups and entries seen on project rows, plus this button's own actions,
|
|
1095
|
+
// placed in their group even before a row has shown them. A removed action's old entry is dropped.
|
|
1096
|
+
const display = {};
|
|
1097
|
+
for (const k of keys) display[k] = { label: cat[k].label || k, entries: Object.entries(cat[k].entries).map(([ek, et]) => ({ key: ek, text: et })) };
|
|
1098
|
+
for (const c of mine) {
|
|
1099
|
+
const gk = keyText(c.group);
|
|
1100
|
+
const g = display[gk] || (display[gk] = { label: c.group, entries: [] });
|
|
1101
|
+
const item = { key: `custom-${c.id}`, text: c.label, custom: c };
|
|
1102
|
+
const at = g.entries.findIndex((x) => x.key === item.key);
|
|
1103
|
+
if (at < 0) g.entries.push(item); else g.entries[at] = item;
|
|
1104
|
+
}
|
|
1105
|
+
for (const g of Object.values(display)) g.entries = g.entries.filter((x) => !x.key.startsWith('custom-') || x.custom);
|
|
1106
|
+
const gkeys = Object.keys(display);
|
|
1107
|
+
const rank = (k) => { const i = m.groups.indexOf(k); return i < 0 ? m.groups.length : i; };
|
|
1108
|
+
const ordered = gkeys.map((k, i) => ({ k, i })).sort((a, b) => rank(a.k) - rank(b.k) || a.i - b.i).map((x) => x.k);
|
|
1109
|
+
// Columns: how many lists the menu shows side by side, and which column each group is in.
|
|
1110
|
+
const nCols = takesColumns(id) ? Math.max(1, (m.columns || []).length) : 1;
|
|
1111
|
+
const colOf = (k) => { if (nCols === 1) return 0; const i = m.columns.findIndex((c) => c.includes(k)); return i < 0 ? nCols - 1 : i; };
|
|
1112
|
+
const entriesOf = (k) => {
|
|
1113
|
+
const o = (m.order || {})[k] || [];
|
|
1114
|
+
const pos = (x) => { const i = o.indexOf(x.key); return i < 0 ? o.length : i; };
|
|
1115
|
+
return display[k].entries.map((x, i) => ({ x, i })).sort((a, b) => pos(a.x) - pos(b.x) || a.i - b.i).map((y) => y.x);
|
|
1116
|
+
};
|
|
1117
|
+
const editAction = (c) => {
|
|
1118
|
+
const f = (n) => `edit_${n}_${esc(c.id)}`;
|
|
1119
|
+
return `<details class="row-edit"><summary class="uk-text-meta">edit</summary>
|
|
1120
|
+
<div class="row-edit-action">
|
|
1121
|
+
<div class="row-add-grid">
|
|
1122
|
+
<label>Label <input class="uk-input uk-form-small" name="${f('label')}" value="${esc(c.label)}" maxlength="60"></label>
|
|
1123
|
+
<label>Group <input class="uk-input uk-form-small" name="${f('group')}" value="${esc(c.group)}" maxlength="40"></label>
|
|
1124
|
+
<label>Shows on <input class="uk-input uk-form-small" name="${f('when')}" value="${esc(c.when.join(' '))}" placeholder="any" title="any, ddev, composer, node, git, workspace:name — each may start with !"></label>
|
|
1125
|
+
<label>Opens <select class="uk-select uk-form-small" name="${f('open')}"><option value="toast"${c.open === 'toast' ? ' selected' : ''}>a toast linking to it</option><option value="screen"${c.open === 'screen' ? ' selected' : ''}>its screen, right away</option></select></label>
|
|
1126
|
+
<label class="row-add-steps">Commands, one per line <span class="uk-text-meta">— or use: <command id></span> <textarea class="uk-textarea uk-form-small" name="${f('steps')}" rows="${Math.min(6, c.steps.length + 1)}" spellcheck="false" data-ace="sh">${esc(c.steps.map(stepText).join('\n'))}</textarea></label>
|
|
1127
|
+
<label class="row-add-check"><input class="uk-checkbox" type="checkbox" name="${f('keepgoing')}" value="1"${c.keepgoing ? ' checked' : ''}> keep going when a step fails</label>
|
|
1128
|
+
<label class="row-add-check"><input class="uk-checkbox" type="checkbox" name="${f('confirm')}" value="1"${c.confirm ? ' checked' : ''}> ask before running</label>
|
|
1129
|
+
</div>
|
|
1130
|
+
${c.options.length || c.steps.some((st) => st.onError) ? '<p class="uk-text-meta">Its options and on_error steps are kept as they are — change those in Definition.</p>' : ''}
|
|
1131
|
+
<button type="button" class="uk-button uk-button-primary uk-button-small" hx-post="/actions/row-custom-save" hx-vals='{"id":"${esc(c.id)}","open_button":"${esc(c.button)}","mtime":"${mtime}","pane":"0"}' ${target}>Save the action</button>
|
|
1132
|
+
<button type="button" class="uk-button uk-button-danger uk-button-small arm-step" hx-post="/actions/row-custom-remove" hx-vals='{"id":"${esc(c.id)}","open_button":"${esc(c.button)}","mtime":"${mtime}","pane":"0"}' ${target} hx-trigger="confirmed-remove">Delete</button>
|
|
1133
|
+
</div>
|
|
1134
|
+
</details>`;
|
|
1135
|
+
};
|
|
1136
|
+
const groups = !hasMenu(id) ? '<p class="uk-text-meta">A single button: nothing inside it to arrange, and nowhere to add an action.</p>'
|
|
1137
|
+
: !gkeys.length ? '<p class="uk-text-meta">Not seen on a project row yet — open a workspace page where this button shows, then come back to arrange what is inside it.</p>'
|
|
1138
|
+
: `<input type="hidden" name="menu_present_${esc(id)}" value="1">
|
|
1139
|
+
${takesColumns(id) ? `<label class="row-columns-pick">Columns in the menu <select class="uk-select uk-form-small" name="columns_${esc(id)}"
|
|
1140
|
+
hx-post="/actions/row-arrange-save" hx-trigger="change" hx-vals='{"pane":"0","open_button":"${esc(id)}"}' ${target}>${[1, 2, 3, 4].map((n) => `<option${n === nCols ? ' selected' : ''}>${n}</option>`).join('')}</select></label>` : `<p class="uk-text-meta">${esc(label)} lays out its own columns.</p>`}
|
|
1141
|
+
<div class="row-columns row-columns-${nCols}">
|
|
1142
|
+
${Array.from({ length: nCols }, (_, col) => `<ol class="row-list row-groups" data-col="${col}" uk-sortable="group: cols-${esc(id)}; handle: .row-handle-group">
|
|
1143
|
+
${ordered.filter((k) => colOf(k) === col).map((k) => `<li class="row-item row-group">
|
|
1144
|
+
<span class="row-handle row-handle-group" uk-icon="icon: table; ratio: .7" title="Drag to reorder the group"></span>
|
|
1145
|
+
<input type="hidden" name="group_${esc(id)}" value="${esc(k)}">
|
|
1146
|
+
<input type="hidden" class="gcol-of" name="gcol_${esc(id)}" data-key="${esc(k)}" value="${esc(k)}	${col}">
|
|
1147
|
+
<div class="row-group-body">
|
|
1148
|
+
<label class="row-group-name"><input class="uk-checkbox" type="checkbox" name="hide_group_${esc(id)}" value="${esc(k)}"${hg.has(k) ? ' checked' : ''}>
|
|
1149
|
+
<strong>${esc(display[k].label)}</strong> <span class="uk-text-meta">hide the group</span></label>
|
|
1150
|
+
<ol class="row-entry-list" uk-sortable="handle: .row-handle-entry">
|
|
1151
|
+
${entriesOf(k).map((x) => `<li class="row-entry-item">
|
|
1152
|
+
<span class="row-handle row-handle-entry" uk-icon="icon: table; ratio: .6" title="Drag to reorder"></span>
|
|
1153
|
+
${moveArrows(x.text)}
|
|
1154
|
+
<input type="hidden" name="entry_${esc(id)}" value="${esc(k)}	${esc(x.key)}">
|
|
1155
|
+
<label class="row-entry"><input class="uk-checkbox" type="checkbox" name="hide_entry_${esc(id)}" value="${esc(x.key)}"${he.has(x.key) ? ' checked' : ''}> ${esc(x.text)}</label>
|
|
1156
|
+
${x.custom
|
|
1157
|
+
? `<span class="uk-label row-entry-yours">yours · ${x.custom.open === 'screen' ? 'opens its screen' : 'toast'}${x.custom.steps.some((s) => s.use) ? ` · uses ${esc(x.custom.steps.filter((s) => s.use).map((s) => s.use).join(', '))}` : ''}</span>${editAction(x.custom)}`
|
|
1158
|
+
: '<span class="uk-text-meta row-entry-builtin" title="A dashboard command: it can be moved or hidden, not deleted">built in</span>'}
|
|
1159
|
+
</li>`).join('')}
|
|
1160
|
+
</ol>
|
|
1161
|
+
</div>
|
|
1162
|
+
${moveArrows('this group')}
|
|
1163
|
+
</li>`).join('')}
|
|
1164
|
+
</ol>`).join('')}
|
|
1165
|
+
</div>
|
|
1166
|
+
<p class="uk-text-meta">Drag a group, or a command inside it, to reorder — a group can also go to another column. A ticked group or command is hidden. Built-in commands can
|
|
1167
|
+
be moved and hidden; your own actions can also be edited and deleted.</p>`;
|
|
1168
|
+
const mineHtml = '';
|
|
1169
|
+
const add = !hasMenu(id) ? '' : `<div class="row-add-action">
|
|
1170
|
+
<span class="uk-text-meta">Add an action to ${esc(label)}</span>
|
|
1171
|
+
${addActionFields(id, keys.map((k) => `<option value="${esc(cat[k].label || k)}">`).join(''))}
|
|
1172
|
+
<button type="button" class="uk-button uk-button-primary uk-button-small" hx-post="/actions/row-custom-add" hx-include="closest .row-add-action"
|
|
1173
|
+
hx-vals='{"for_button":"${esc(id)}","open_button":"${esc(id)}","mtime":"${mtime}","pane":"0"}' ${target}><span uk-icon="icon: plus; ratio: .7"></span> Add the action</button>
|
|
1174
|
+
</div>`;
|
|
1175
|
+
return `<li class="row-item row-button" data-button="${esc(id)}">
|
|
1176
|
+
<span class="row-handle row-handle-button" uk-icon="icon: table; ratio: .7" title="Drag to another place or row"></span>
|
|
1177
|
+
<input type="hidden" name="button" value="${esc(id)}">
|
|
1178
|
+
<input type="hidden" class="row-of" name="row_${esc(id)}" value="${where}">
|
|
1179
|
+
<details class="row-button-body"${id === openButton ? ' open' : ''}>
|
|
1180
|
+
<summary><strong>${esc(label)}</strong>${BUILTIN_IDS.has(id) ? '' : ' <span class="uk-label">yours</span>'} <span class="uk-text-meta">· ${bits}</span></summary>
|
|
1181
|
+
${groups}${mineHtml}${add}
|
|
1182
|
+
</details>
|
|
1183
|
+
<select class="uk-select uk-form-small row-move" aria-label="Row for ${esc(label)}">${whereOptions(where)}</select>
|
|
1184
|
+
<!-- How much of the button is drawn. The same three answers the navigation entries give, and the
|
|
1185
|
+
same word for them: a row of buttons and a rail of entries are the same question about room.
|
|
1186
|
+
Both halves stay in the markup whichever is picked, so a screen reader still reads the name. -->
|
|
1187
|
+
<select class="uk-select uk-form-small row-show" name="show" aria-label="How much of ${esc(label)} is shown"
|
|
1188
|
+
title="How much of ${esc(label)} is shown on a project row">
|
|
1189
|
+
<option value="${esc(id)}:both"${shownAs ? '' : ' selected'}>icon + text</option>
|
|
1190
|
+
<option value="${esc(id)}:icon"${shownAs === 'icon' ? ' selected' : ''}>icon only</option>
|
|
1191
|
+
<option value="${esc(id)}:text"${shownAs === 'text' ? ' selected' : ''}>text only</option>
|
|
1192
|
+
</select>
|
|
1193
|
+
${moveArrows(label)}
|
|
1194
|
+
</li>`;
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
const rowTool = (op, i, icon, title, disabled) => `<button type="button" class="uk-button uk-button-default uk-button-small" hx-post="/actions/row-rows"
|
|
1198
|
+
hx-vals='{"op":"${op}","index":"${i}","pane":"0"}' ${target}${disabled ? ' disabled' : ''} title="${esc(title)}"><span uk-icon="icon: ${icon}; ratio: .7"></span></button>`;
|
|
1199
|
+
|
|
1200
|
+
const rowsPane = `<form id="row-arrange-form" hx-post="/actions/row-arrange-save" ${target}>
|
|
1201
|
+
${mt}<input type="hidden" name="pane" value="0">
|
|
1202
|
+
<p class="uk-text-meta">Every project row is built from these rows of buttons, top to bottom. Drag a button to another row, or
|
|
1203
|
+
pick its row; open a button to order its groups, hide what you do not use, and add actions to it. Changes to rows and buttons
|
|
1204
|
+
apply when you save; adding or removing an action, or a row, saves the rest with it.</p>
|
|
1205
|
+
${layout.rows.map((ids, i) => `<section class="row-block" data-row="${i}">
|
|
1206
|
+
<header class="row-block-head"><strong>Row ${i + 1}</strong>${i === 0 ? ' <span class="uk-text-meta">· beside the running and test badges</span>' : ''}
|
|
1207
|
+
<span class="row-block-tools">${rowTool('up', i, 'chevron-up', 'Move this row up', i === 0)}${rowTool('down', i, 'chevron-down', 'Move this row down', i === layout.rows.length - 1)}${rowTool('remove', i, 'trash', 'Remove this row — its buttons move to the row above', layout.rows.length === 1)}</span>
|
|
1208
|
+
</header>
|
|
1209
|
+
<ol class="row-list row-buttons" data-row="${i}" uk-sortable="group: row-buttons; handle: .row-handle-button">${ids.map((id) => buttonCard(id, i)).join('')}</ol>
|
|
1210
|
+
</section>`).join('')}
|
|
1211
|
+
<p><button type="button" class="uk-button uk-button-default uk-button-small" hx-get="/fragments/row/add-row"
|
|
1212
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML"${layout.rows.length >= MAX_ROWS ? ' disabled' : ''}><span uk-icon="icon: plus; ratio: .7"></span> Add a row</button></p>
|
|
1213
|
+
<section class="row-block row-block-hidden" data-row="hidden">
|
|
1214
|
+
<header class="row-block-head"><strong>Hidden</strong> <span class="uk-text-meta">· not shown on any project row</span></header>
|
|
1215
|
+
<ol class="row-list row-buttons" data-row="hidden" uk-sortable="group: row-buttons; handle: .row-handle-button">${layout.hidden.map((id) => buttonCard(id, 'hidden')).join('')}</ol>
|
|
1216
|
+
</section>
|
|
1217
|
+
<section class="row-block row-add-button">
|
|
1218
|
+
<header class="row-block-head"><strong>Add a button</strong> <span class="uk-text-meta">· a menu of your own, made by its first action</span></header>
|
|
1219
|
+
<p><button type="button" class="uk-button uk-button-default uk-button-small" hx-get="/fragments/row/add-button"
|
|
1220
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: plus; ratio: .7"></span> Add a button</button></p>
|
|
1221
|
+
</section>
|
|
1222
|
+
<!-- Stays at the bottom of the screen while the form is in view: the form is long, and Save used to be
|
|
1223
|
+
two screens away from whatever was just changed. ui.js marks the form as changed. -->
|
|
1224
|
+
<div class="row-save-bar">
|
|
1225
|
+
<span class="row-unsaved-note" role="status">Unsaved changes</span>
|
|
1226
|
+
<button type="submit" class="uk-button uk-button-primary"><span uk-icon="icon: check; ratio: .8"></span> Save the rows</button>
|
|
1227
|
+
</div>
|
|
1228
|
+
</form>`;
|
|
1229
|
+
|
|
1230
|
+
const live = liveSessions();
|
|
1231
|
+
const runs = readRuns().slice(-40).reverse();
|
|
1232
|
+
const stateLabel = { succeeded: 'uk-label-success', failed: 'uk-label-danger', partly: 'uk-label-warning', running: 'uk-label-warning', unknown: '' };
|
|
1233
|
+
const activityPane = runs.length ? `<p class="uk-text-meta">The last runs of your own actions, newest first. One still going links to its session.</p>
|
|
1234
|
+
<div class="uk-overflow-auto"><table class="uk-table uk-table-small uk-table-divider row-activity">
|
|
1235
|
+
<thead><tr><th>Started</th><th>Action</th><th>Project</th><th>Result</th><th></th></tr></thead>
|
|
1236
|
+
<tbody>${runs.map((r) => {
|
|
1237
|
+
const isLive = live.has(r.session);
|
|
1238
|
+
const st = r.status || runStatus(sessionLog(r.session), isLive);
|
|
1239
|
+
const tail = r.status ? '' : sessionLog(r.session).split('\n').filter((l) => l.trim()).slice(-12).join('\n');
|
|
1240
|
+
return `<tr><td>${esc(new Date(r.at).toLocaleString())}</td><td>${esc(r.label)}</td><td>${esc(r.workspace)}/${esc(r.project)}</td>
|
|
1241
|
+
<td><span class="uk-label ${stateLabel[st.state] || ''}">${esc(st.state)}</span>${st.note ? ` <span class="uk-text-meta">${esc(st.note)}</span>` : ''}</td>
|
|
1242
|
+
<td>${isLive ? `<a href="/terminal/${encodeURIComponent(r.session)}" target="_blank" rel="noopener">open</a>`
|
|
1243
|
+
: (tail ? `<details><summary>output</summary><pre class="row-activity-log">${esc(tail)}</pre></details>` : '')}</td></tr>`;
|
|
1244
|
+
}).join('')}</tbody></table></div>`
|
|
1245
|
+
: '<p class="uk-text-meta">No action has run yet. Runs of your own actions are listed here, with how each one ended.</p>';
|
|
1246
|
+
|
|
1247
|
+
const definitionPane = `<form id="row-definition-form" hx-post="/actions/row-definition-save" ${target}>
|
|
1248
|
+
${mt}<input type="hidden" name="pane" value="2">
|
|
1249
|
+
<p class="uk-text-meta">The whole file as YAML — the same thing the Rows tab edits. Copy it to keep or share; paste one in to load
|
|
1250
|
+
it. <strong>Check</strong> says what would change and saves nothing.</p>
|
|
1251
|
+
<textarea class="uk-textarea row-definition-text" name="definition" rows="22" spellcheck="false" data-ace="yaml">${esc(definitionText())}</textarea>
|
|
1252
|
+
<div class="row-definition-buttons">
|
|
1253
|
+
<select class="uk-select uk-form-small" name="mode" aria-label="What to load">
|
|
1254
|
+
<option value="replace">Replace the whole file</option>
|
|
1255
|
+
<option value="skip">Load only its actions — skip an id that already exists</option>
|
|
1256
|
+
<option value="update">Load only its actions — update an id that already exists</option>
|
|
1257
|
+
</select>
|
|
1258
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" hx-post="/actions/row-definition-check" hx-target="#row-definition-report" hx-swap="innerHTML">Check</button>
|
|
1259
|
+
<button type="submit" class="uk-button uk-button-primary uk-button-small"><span uk-icon="icon: check; ratio: .7"></span> Save</button>
|
|
1260
|
+
</div>
|
|
1261
|
+
<div id="row-definition-report"></div>
|
|
1262
|
+
<details class="row-definition-help"><summary>An action with every field</summary><textarea class="uk-textarea" rows="${EXAMPLE.split('\n').length + 1}" readonly spellcheck="false"
|
|
1263
|
+
data-ace="yaml" data-ace-readonly="1" aria-label="An action with every field">${esc(EXAMPLE)}</textarea>
|
|
1264
|
+
<p class="uk-text-meta"><code>when</code>: every clause must match — any, ddev, composer, node, git, workspace:<name>, each with an optional !.
|
|
1265
|
+
<code>{{opt.name}}</code> puts an option's value into a step, checked and single-quoted. <code>keepgoing</code> runs every step and says
|
|
1266
|
+
which failed; without it the first failure stops the rest. <code>on_error</code> runs when its step fails, and its result becomes the step's.</p>
|
|
1267
|
+
</details>
|
|
1268
|
+
<h4>Start again</h4>
|
|
1269
|
+
<p class="uk-text-meta">Deletes <code>${esc(layoutFile())}</code>: every button goes back to its default row, every group and command
|
|
1270
|
+
shows, and your own actions are removed.</p>
|
|
1271
|
+
<button type="button" class="uk-button uk-button-danger uk-button-small arm-step"
|
|
1272
|
+
hx-post="/actions/row-layout-reset" hx-vals='{"mtime":"${mtime}","pane":"2"}' ${target} hx-trigger="confirmed-remove">Reset to the defaults</button>
|
|
1273
|
+
</form>`;
|
|
1274
|
+
|
|
1275
|
+
const active = [0, 1, 2].includes(Number(pane)) ? Number(pane) : 0;
|
|
1276
|
+
return `<div id="row-actions-editor">
|
|
1277
|
+
${message}
|
|
1278
|
+
${layout.error ? `<div class="msg error">${esc(layoutFile())} could not be read (${esc(layout.error)}), so the defaults are in use. Saving here replaces it.</div>` : ''}
|
|
1279
|
+
<ul class="uk-subnav uk-subnav-pill row-actions-tabs" uk-switcher="connect: #row-actions-panes; active: ${active}">
|
|
1280
|
+
<li><a href="#">Rows</a></li><li><a href="#">Activity</a></li><li><a href="#">Definition</a></li>
|
|
1281
|
+
</ul>
|
|
1282
|
+
<ul id="row-actions-panes" class="uk-switcher">
|
|
1283
|
+
<li>${rowsPane}</li>
|
|
1284
|
+
<li>${activityPane}</li>
|
|
1285
|
+
<li>${definitionPane}</li>
|
|
1286
|
+
</ul>
|
|
1287
|
+
</div>`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** The layout a submitted Rows form describes: which row each button is in, and each opened menu's groups. */
|
|
1291
|
+
function arrangementFromForm(layout, form) {
|
|
1292
|
+
const list = (v) => (Array.isArray(v) ? v : (v === undefined || v === null ? [] : [v])).map((x) => String(x));
|
|
1293
|
+
const known = new Set(knownButtonIds(layout));
|
|
1294
|
+
const rows = layout.rows.map(() => []);
|
|
1295
|
+
const hidden = [];
|
|
1296
|
+
const placed = new Set();
|
|
1297
|
+
for (const id of list(form.button)) {
|
|
1298
|
+
if (!known.has(id) || placed.has(id)) continue;
|
|
1299
|
+
const where = String(form[`row_${id}`] === undefined ? '' : form[`row_${id}`]);
|
|
1300
|
+
if (where === 'hidden') { hidden.push(id); placed.add(id); continue; }
|
|
1301
|
+
const i = Number(where);
|
|
1302
|
+
if (Number.isInteger(i) && i >= 0 && i < rows.length) { rows[i].push(id); placed.add(id); }
|
|
1303
|
+
}
|
|
1304
|
+
// A button the form did not carry keeps the place it had, rather than falling back to its default.
|
|
1305
|
+
layout.rows.forEach((ids, i) => ids.forEach((id) => { if (!placed.has(id)) { rows[i].push(id); placed.add(id); } }));
|
|
1306
|
+
layout.hidden.forEach((id) => { if (!placed.has(id)) { hidden.push(id); placed.add(id); } });
|
|
1307
|
+
|
|
1308
|
+
const doc = toDoc(layout);
|
|
1309
|
+
doc.rows = rows;
|
|
1310
|
+
doc.hidden = hidden;
|
|
1311
|
+
// How much of each button is drawn: `<id>:icon`, `<id>:text` or `<id>:both`. Both is what a button is
|
|
1312
|
+
// until somebody says otherwise, so it is not written down — and posting it explicitly is what makes
|
|
1313
|
+
// going back to both work. A button the form did not carry keeps the answer it had.
|
|
1314
|
+
const show = { ...(layout.show || {}) };
|
|
1315
|
+
for (const v of list(form.show)) {
|
|
1316
|
+
const at = v.lastIndexOf(':');
|
|
1317
|
+
if (at < 0) continue;
|
|
1318
|
+
const id = v.slice(0, at);
|
|
1319
|
+
const how = v.slice(at + 1);
|
|
1320
|
+
if (!known.has(id)) continue;
|
|
1321
|
+
if (how === 'icon' || how === 'text') show[id] = how; else delete show[id];
|
|
1322
|
+
}
|
|
1323
|
+
if (Object.keys(show).length) doc.show = show; else delete doc.show;
|
|
1324
|
+
for (const id of known) {
|
|
1325
|
+
if (!form[`menu_present_${id}`]) continue;
|
|
1326
|
+
// Only what the form could have shown is taken from it; anything this machine has not seen stays as it was.
|
|
1327
|
+
const cat = seenMenu(id).groups;
|
|
1328
|
+
const groupKeys = new Set(Object.keys(cat));
|
|
1329
|
+
for (const c of layout.custom.filter((x) => x.button === id)) groupKeys.add(keyText(c.group));
|
|
1330
|
+
const entryKeys = new Set([...Object.values(cat).flatMap((g) => Object.keys(g.entries)), ...layout.custom.filter((x) => x.button === id).map((x) => `custom-${x.id}`)]);
|
|
1331
|
+
const old = layout.menus[id] || { groups: [], hiddenGroups: [], hiddenEntries: [], order: {}, columns: [] };
|
|
1332
|
+
const order = {};
|
|
1333
|
+
for (const v of list(form[`entry_${id}`])) {
|
|
1334
|
+
const [g, e] = v.split('\t');
|
|
1335
|
+
if (!groupKeys.has(g) || !entryKeys.has(e)) continue;
|
|
1336
|
+
(order[g] || (order[g] = [])).push(e);
|
|
1337
|
+
}
|
|
1338
|
+
for (const [g, keys] of Object.entries(old.order || {})) if (!order[g] && !groupKeys.has(g)) order[g] = keys;
|
|
1339
|
+
const nCols = takesColumns(id) ? Math.min(MAX_COLUMNS, Math.max(1, Number(form[`columns_${id}`]) || 1)) : 1;
|
|
1340
|
+
const columns = nCols > 1 ? Array.from({ length: nCols }, () => []) : [];
|
|
1341
|
+
if (nCols > 1) {
|
|
1342
|
+
for (const v of list(form[`gcol_${id}`])) {
|
|
1343
|
+
const [g, c] = v.split('\t');
|
|
1344
|
+
if (!groupKeys.has(g)) continue;
|
|
1345
|
+
const ci = Number(c);
|
|
1346
|
+
// A column that no longer exists hands its groups to the last one.
|
|
1347
|
+
columns[Number.isInteger(ci) && ci >= 0 && ci < nCols ? ci : nCols - 1].push(g);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
// Only what differs from what the menu shows by default is kept. Every card posts its whole order on every
|
|
1351
|
+
// save, so a single change used to write the order of all twelve menus — freezing today's order for menus
|
|
1352
|
+
// nobody touched, and pinning it even after the dashboard changes a menu of its own.
|
|
1353
|
+
const naturalGroups = [...Object.keys(cat), ...[...groupKeys].filter((g) => !(g in cat))];
|
|
1354
|
+
const naturalEntries = (g) => [...Object.keys((cat[g] || {}).entries || {}),
|
|
1355
|
+
...layout.custom.filter((x) => x.button === id && keyText(x.group) === g).map((x) => `custom-${x.id}`)];
|
|
1356
|
+
for (const g of Object.keys(order)) {
|
|
1357
|
+
if (!groupKeys.has(g)) continue;
|
|
1358
|
+
const natural = naturalEntries(g).filter((e) => order[g].includes(e));
|
|
1359
|
+
if (order[g].join('\n') === natural.join('\n')) delete order[g];
|
|
1360
|
+
}
|
|
1361
|
+
doc.menus[id] = {
|
|
1362
|
+
columns,
|
|
1363
|
+
order,
|
|
1364
|
+
groups: (() => {
|
|
1365
|
+
const sent = list(form[`group_${id}`]).filter((k) => groupKeys.has(k));
|
|
1366
|
+
const natural = naturalGroups.filter((g) => sent.includes(g));
|
|
1367
|
+
return [...(sent.join('\n') === natural.join('\n') ? [] : sent), ...old.groups.filter((k) => !groupKeys.has(k))];
|
|
1368
|
+
})(),
|
|
1369
|
+
hidden_groups: [...list(form[`hide_group_${id}`]).filter((k) => groupKeys.has(k)), ...old.hiddenGroups.filter((k) => !groupKeys.has(k))],
|
|
1370
|
+
hidden_entries: [...list(form[`hide_entry_${id}`]).filter((k) => entryKeys.has(k)), ...old.hiddenEntries.filter((k) => !entryKeys.has(k))],
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
return doc;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
module.exports = {
|
|
1377
|
+
registerButton,
|
|
1378
|
+
BUILTIN_BUTTONS,
|
|
1379
|
+
BUILTIN_IDS,
|
|
1380
|
+
DEFAULT_ROWS,
|
|
1381
|
+
// The first version called rows lines; the tests of the menus still ask for it by that name.
|
|
1382
|
+
DEFAULT_LINES: DEFAULT_ROWS,
|
|
1383
|
+
MAX_ROWS,
|
|
1384
|
+
WHENS,
|
|
1385
|
+
layoutFile,
|
|
1386
|
+
seenFile,
|
|
1387
|
+
historyFile,
|
|
1388
|
+
normalizeLayout,
|
|
1389
|
+
toDoc,
|
|
1390
|
+
customToDoc,
|
|
1391
|
+
knownButtonIds,
|
|
1392
|
+
buttonLabel,
|
|
1393
|
+
hasMenu,
|
|
1394
|
+
takesColumns,
|
|
1395
|
+
MAX_COLUMNS,
|
|
1396
|
+
columnizeButtonHtml,
|
|
1397
|
+
whenList,
|
|
1398
|
+
describeWhen,
|
|
1399
|
+
whenMatches,
|
|
1400
|
+
stepText,
|
|
1401
|
+
usedCommands,
|
|
1402
|
+
useRefusal,
|
|
1403
|
+
effectiveOptions,
|
|
1404
|
+
cleanCustom,
|
|
1405
|
+
customRefusal,
|
|
1406
|
+
optionValueRefusal,
|
|
1407
|
+
resolveOptions,
|
|
1408
|
+
actionCommandLine,
|
|
1409
|
+
runStatus,
|
|
1410
|
+
slug,
|
|
1411
|
+
loadRowLayout,
|
|
1412
|
+
loadRowLayoutFresh,
|
|
1413
|
+
invalidateRowLayout,
|
|
1414
|
+
layoutMtime,
|
|
1415
|
+
saveRowLayout,
|
|
1416
|
+
resetRowLayout,
|
|
1417
|
+
definitionText,
|
|
1418
|
+
checkDefinition,
|
|
1419
|
+
seenMenu,
|
|
1420
|
+
loadSeen,
|
|
1421
|
+
recordRun,
|
|
1422
|
+
readRuns,
|
|
1423
|
+
settleRun,
|
|
1424
|
+
liveSessions,
|
|
1425
|
+
sessionLog,
|
|
1426
|
+
plainText,
|
|
1427
|
+
entryKey,
|
|
1428
|
+
groupKeyOf,
|
|
1429
|
+
splitTopLevelLi,
|
|
1430
|
+
arrangeNavInner,
|
|
1431
|
+
arrangeButtonHtml,
|
|
1432
|
+
customButtonHtml,
|
|
1433
|
+
rowLinesHtml,
|
|
1434
|
+
actionOptionsFormHtml,
|
|
1435
|
+
addRowFormHtml,
|
|
1436
|
+
addButtonFormHtml,
|
|
1437
|
+
rowActionsEditorHtml,
|
|
1438
|
+
arrangementFromForm,
|
|
1439
|
+
withinBudget,
|
|
1440
|
+
};
|