pressship 0.1.14 → 0.1.15

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/assets/web/app.js CHANGED
@@ -3,6 +3,10 @@
3
3
  let token = document.querySelector('meta[name="pressship-token"]').content;
4
4
 
5
5
  let markdownParser = basicMarkdownToHtml;
6
+ let studioPackageSizePollTimer = null;
7
+ let monacoConfigured = false;
8
+
9
+ const STUDIO_CLI_PREFIX = "npx pressship";
6
10
 
7
11
  void import("/vendor/marked.esm.js")
8
12
  .then(({ marked }) => {
@@ -202,7 +206,7 @@ function studioTheme() {
202
206
  }
203
207
 
204
208
  function studioMonacoTheme() {
205
- return studioTheme() === "light" ? "vs" : "vs-dark";
209
+ return studioTheme() === "light" ? "pressship-studio-light" : "pressship-studio-dark";
206
210
  }
207
211
 
208
212
  function createInitialStudioRelease() {
@@ -227,7 +231,31 @@ function createInitialStudioRelease() {
227
231
  switchingResolution: "",
228
232
  switchJobId: null,
229
233
  switchConflict: null,
230
- switchError: ""
234
+ switchError: "",
235
+ ignoredCollapsed: true
236
+ };
237
+ }
238
+
239
+ function createInitialStudioIgnoreState() {
240
+ return {
241
+ ignorePath: "",
242
+ patterns: [],
243
+ ignoredFiles: []
244
+ };
245
+ }
246
+
247
+ function createInitialStudioPackageSize() {
248
+ return {
249
+ loading: false,
250
+ error: "",
251
+ sizeBytes: null,
252
+ maxSizeBytes: 10 * 1024 * 1024,
253
+ overLimit: false,
254
+ fileCount: 0,
255
+ topLevelFolder: "",
256
+ largestFiles: [],
257
+ calculatedAt: null,
258
+ stale: false
231
259
  };
232
260
  }
233
261
 
@@ -400,6 +428,7 @@ const state = {
400
428
  id: null,
401
429
  plugin: null,
402
430
  files: [],
431
+ directories: [],
403
432
  selectedFile: null,
404
433
  fileContent: "",
405
434
  draftContent: "",
@@ -420,6 +449,8 @@ const state = {
420
449
  openFiles: [],
421
450
  terminalOpen: true,
422
451
  collapsedFolders: new Set(),
452
+ expandedIgnoredFolders: new Set(),
453
+ loadingFolders: new Set(),
423
454
  terminal: [],
424
455
  aiPrompt: "",
425
456
  aiJobId: null,
@@ -438,6 +469,12 @@ const state = {
438
469
  sidebarTab: "ai",
439
470
  playgroundVersionModal: null,
440
471
  release: createInitialStudioRelease(),
472
+ ignoreState: createInitialStudioIgnoreState(),
473
+ packageSize: createInitialStudioPackageSize(),
474
+ ignoreLoading: false,
475
+ ignoreError: "",
476
+ ignoreBusyPattern: "",
477
+ ignoreBusyPath: "",
441
478
  pendingConfirms: new Map()
442
479
  },
443
480
  activeView: "dashboard",
@@ -504,6 +541,9 @@ const ROUTE_VIEW_ALIASES = {
504
541
  };
505
542
  let applyingLocationRoute = false;
506
543
  let initialRouteLoaderVisible = false;
544
+ let studioFileSyncInFlight = false;
545
+ let studioLastExternalSyncAt = 0;
546
+ let studioLastDiskConflictKey = "";
507
547
 
508
548
  document.querySelectorAll("[data-kbd-mod]").forEach((node) => {
509
549
  node.textContent = isMac ? "⌘" : "Ctrl+";
@@ -564,6 +604,14 @@ document.addEventListener("keydown", (event) => {
564
604
  return;
565
605
  }
566
606
 
607
+ if (mod && event.key.toLowerCase() === "s" && !event.shiftKey && !event.altKey && state.activeView === "studio") {
608
+ event.preventDefault();
609
+ if (canSaveStudioFile()) {
610
+ void saveStudioFile();
611
+ }
612
+ return;
613
+ }
614
+
567
615
  if (event.key === "Escape" && state.command.open) {
568
616
  event.preventDefault();
569
617
  closeCommandPalette();
@@ -611,6 +659,16 @@ window.addEventListener("popstate", () => {
611
659
  void applyLocationRoute({ replaceRoute: true });
612
660
  });
613
661
 
662
+ window.addEventListener("focus", () => {
663
+ syncSelectedStudioFileOnResume();
664
+ });
665
+
666
+ document.addEventListener("visibilitychange", () => {
667
+ if (!document.hidden) {
668
+ syncSelectedStudioFileOnResume();
669
+ }
670
+ });
671
+
614
672
  function normalizeViewId(view) {
615
673
  return Object.prototype.hasOwnProperty.call(VIEW_ROUTE_PATHS, view) ? view : "dashboard";
616
674
  }
@@ -722,6 +780,7 @@ function primeInitialRouteState(route) {
722
780
  id: route.studio.project,
723
781
  plugin: { slug: route.studio.project, name: route.studio.project },
724
782
  files: [],
783
+ directories: [],
725
784
  selectedFile: filePath
726
785
  ? {
727
786
  path: filePath,
@@ -741,8 +800,13 @@ function primeInitialRouteState(route) {
741
800
  checkJobId: null,
742
801
  activeTab: "editor",
743
802
  openFiles: filePath ? [filePath] : [],
744
- terminal: [`Opening ${route.studio.project} from URL...`],
803
+ terminal: [
804
+ `Opening ${route.studio.project} from URL...`,
805
+ { message: `$ ${studioOpenCliCommand()}`, tone: "command" }
806
+ ],
745
807
  collapsedFolders: new Set(),
808
+ expandedIgnoredFolders: new Set(),
809
+ loadingFolders: new Set(),
746
810
  pendingConfirms: new Map()
747
811
  };
748
812
  }
@@ -926,12 +990,30 @@ async function runAction(name, element) {
926
990
  await selectStudioFile(element.dataset.path);
927
991
  return;
928
992
 
993
+ case "studio-ignore-file":
994
+ await addStudioIgnoreRule(studioFileIgnorePattern(element.dataset.path), {
995
+ path: element.dataset.path,
996
+ label: element.dataset.path
997
+ });
998
+ return;
999
+
1000
+ case "studio-ignore-folder":
1001
+ await addStudioIgnoreRule(studioFolderIgnorePattern(element.dataset.path), {
1002
+ path: element.dataset.path,
1003
+ label: `${element.dataset.path}/`
1004
+ });
1005
+ return;
1006
+
1007
+ case "studio-unignore-rule":
1008
+ await removeStudioIgnoreRule(element.dataset.pattern);
1009
+ return;
1010
+
929
1011
  case "studio-close-file-tab":
930
1012
  await closeStudioFileTab(element.dataset.path);
931
1013
  return;
932
1014
 
933
1015
  case "studio-toggle-folder":
934
- toggleStudioFolder(element.dataset.folder);
1016
+ await toggleStudioFolder(element.dataset.folder);
935
1017
  return;
936
1018
 
937
1019
  case "studio-toggle-terminal":
@@ -958,6 +1040,10 @@ async function runAction(name, element) {
958
1040
  await runStudioCheck();
959
1041
  return;
960
1042
 
1043
+ case "studio-package-size":
1044
+ await refreshStudioPackageSize({ force: true, notify: true });
1045
+ return;
1046
+
961
1047
  case "studio-toggle-theme":
962
1048
  toggleStudioTheme();
963
1049
  return;
@@ -1075,7 +1161,14 @@ async function runAction(name, element) {
1075
1161
  return;
1076
1162
 
1077
1163
  case "studio-release-refresh":
1078
- await loadStudioReleaseTags({ force: true });
1164
+ await Promise.all([
1165
+ loadStudioReleaseTags({ force: true }),
1166
+ refreshStudioIgnoreState({ files: true })
1167
+ ]);
1168
+ return;
1169
+
1170
+ case "studio-release-toggle-ignored":
1171
+ toggleStudioReleaseIgnored();
1079
1172
  return;
1080
1173
 
1081
1174
  case "studio-release-switch":
@@ -1437,6 +1530,7 @@ async function openStudio(scope, id, options = {}) {
1437
1530
  id,
1438
1531
  plugin: null,
1439
1532
  files: [],
1533
+ directories: [],
1440
1534
  selectedFile: null,
1441
1535
  fileContent: "",
1442
1536
  draftContent: "",
@@ -1457,7 +1551,12 @@ async function openStudio(scope, id, options = {}) {
1457
1551
  openFiles: [],
1458
1552
  terminalOpen: true,
1459
1553
  collapsedFolders: new Set(),
1460
- terminal: [`Pressship Studio opened for ${scope === "local" ? "local plugin" : "WordPress.org plugin"} ${id}.`],
1554
+ expandedIgnoredFolders: new Set(),
1555
+ loadingFolders: new Set(),
1556
+ terminal: [
1557
+ `Pressship Studio opened for ${scope === "local" ? "local plugin" : "WordPress.org plugin"} ${id}.`,
1558
+ { message: `$ ${studioOpenCliCommand()}`, tone: "command" }
1559
+ ],
1461
1560
  aiPrompt: "",
1462
1561
  aiJobId: null,
1463
1562
  aiRunning: false,
@@ -1475,6 +1574,12 @@ async function openStudio(scope, id, options = {}) {
1475
1574
  sidebarTab,
1476
1575
  playgroundVersionModal: null,
1477
1576
  release: createInitialStudioRelease(),
1577
+ ignoreState: createInitialStudioIgnoreState(),
1578
+ packageSize: createInitialStudioPackageSize(),
1579
+ ignoreLoading: false,
1580
+ ignoreError: "",
1581
+ ignoreBusyPattern: "",
1582
+ ignoreBusyPath: "",
1478
1583
  pendingConfirms: new Map()
1479
1584
  };
1480
1585
  saveStudioSidebarTab(pluginKey, sidebarTab);
@@ -1490,12 +1595,15 @@ async function openStudio(scope, id, options = {}) {
1490
1595
  state.studio.loading = false;
1491
1596
 
1492
1597
  if (scope === "local") {
1493
- const [result, checkState] = await Promise.all([
1598
+ const [result, checkState, ignoreState] = await Promise.all([
1494
1599
  api(`/api/plugins/local/${encodeURIComponent(id)}/files`),
1495
- api(`/api/plugins/local/${encodeURIComponent(id)}/check-state`).catch(() => ({ state: null }))
1600
+ api(`/api/plugins/local/${encodeURIComponent(id)}/check-state`).catch(() => ({ state: null })),
1601
+ api(`/api/plugins/local/${encodeURIComponent(id)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
1496
1602
  ]);
1497
1603
  state.studio.files = result.files ?? [];
1604
+ state.studio.directories = result.directories ?? [];
1498
1605
  applyStudioCheckState(checkState.state);
1606
+ applyStudioIgnoreState(ignoreState);
1499
1607
  renderStudio();
1500
1608
  const requestedFile = options.filePath
1501
1609
  ? state.studio.files.find((file) => file.path === options.filePath)
@@ -1516,6 +1624,7 @@ async function openStudio(scope, id, options = {}) {
1516
1624
  updateRouteFromState({ replace: options.replaceRoute });
1517
1625
  }
1518
1626
  }
1627
+ void refreshStudioPackageSize({ render: true });
1519
1628
  } else {
1520
1629
  state.studio.files = [{ path: "readme.txt", name: "readme.txt", directory: "", size: detail.readme?.length ?? 0 }];
1521
1630
  state.studio.selectedFile = state.studio.files[0];
@@ -1587,7 +1696,88 @@ async function selectStudioFile(relativePath, options = {}) {
1587
1696
  remountStudioEditorIfNeeded();
1588
1697
  } catch (error) {
1589
1698
  appendStudioTerminal(error.message, "error");
1699
+ state.studio.fileContent = `Cannot open ${relativePath}.\n\n${error.message}`;
1700
+ state.studio.draftContent = state.studio.fileContent;
1701
+ state.studio.dirty = false;
1590
1702
  renderStudio();
1703
+ remountStudioEditorIfNeeded();
1704
+ }
1705
+ }
1706
+
1707
+ function syncSelectedStudioFileOnResume() {
1708
+ if (Date.now() - studioLastExternalSyncAt < 1200) {
1709
+ return;
1710
+ }
1711
+ studioLastExternalSyncAt = Date.now();
1712
+ void syncSelectedStudioFileFromDisk({ reason: "external" });
1713
+ }
1714
+
1715
+ async function syncSelectedStudioFileFromDisk(options = {}) {
1716
+ if (
1717
+ studioFileSyncInFlight ||
1718
+ state.studio.scope !== "local" ||
1719
+ !state.studio.id ||
1720
+ !state.studio.selectedFile?.path ||
1721
+ state.studio.activeTab !== "editor" ||
1722
+ state.studio.loading ||
1723
+ state.studio.readOnly ||
1724
+ studioAiChangedFile(state.studio.selectedFile.path)
1725
+ ) {
1726
+ return false;
1727
+ }
1728
+
1729
+ const selectedPath = state.studio.selectedFile.path;
1730
+ studioFileSyncInFlight = true;
1731
+ captureStudioEditorValue();
1732
+
1733
+ try {
1734
+ const result = await api(
1735
+ `/api/plugins/local/${encodeURIComponent(state.studio.id)}/files/content?path=${encodeURIComponent(selectedPath)}`
1736
+ );
1737
+ if (state.studio.selectedFile?.path !== selectedPath) {
1738
+ return false;
1739
+ }
1740
+
1741
+ const diskContent = result.content ?? "";
1742
+ if (diskContent === state.studio.fileContent) {
1743
+ return false;
1744
+ }
1745
+
1746
+ if (state.studio.dirty && state.studio.draftContent !== diskContent) {
1747
+ const conflictKey = `${selectedPath}:${diskContent.length}:${diskContent.slice(0, 80)}`;
1748
+ if (studioLastDiskConflictKey !== conflictKey) {
1749
+ studioLastDiskConflictKey = conflictKey;
1750
+ appendStudioTerminal(
1751
+ `${selectedPath} changed on disk. Your unsaved editor changes were kept.`,
1752
+ "warning"
1753
+ );
1754
+ notice(`${selectedPath} changed on disk. Unsaved editor changes were kept.`, "warning");
1755
+ }
1756
+ return false;
1757
+ }
1758
+
1759
+ studioLastDiskConflictKey = "";
1760
+ state.studio.selectedFile =
1761
+ state.studio.files.find((file) => file.path === result.path) ?? state.studio.selectedFile;
1762
+ state.studio.fileContent = diskContent;
1763
+ state.studio.draftContent = diskContent;
1764
+ state.studio.dirty = false;
1765
+ renderStudio();
1766
+ remountStudioEditorIfNeeded();
1767
+ updateStudioControls();
1768
+ if (options.reason === "ignore-rule") {
1769
+ appendStudioTerminal(`Reloaded ${selectedPath} after ignore rules changed.`, "success");
1770
+ } else if (options.reason === "external") {
1771
+ appendStudioTerminal(`Reloaded ${selectedPath} from disk.`, "status");
1772
+ }
1773
+ return true;
1774
+ } catch (error) {
1775
+ if (options.reportErrors) {
1776
+ appendStudioTerminal(`Could not refresh ${selectedPath}: ${error.message}`, "error");
1777
+ }
1778
+ return false;
1779
+ } finally {
1780
+ studioFileSyncInFlight = false;
1591
1781
  }
1592
1782
  }
1593
1783
 
@@ -1623,6 +1813,10 @@ async function saveStudioFile() {
1623
1813
  state.studio.draftContent = content;
1624
1814
  state.studio.dirty = false;
1625
1815
  appendStudioTerminal(`Saved ${state.studio.selectedFile.path}.`, "success");
1816
+ markStudioPackageSizeStale();
1817
+ if (state.studio.selectedFile.path === ".pressshipignore") {
1818
+ await refreshStudioIgnoreState({ files: true, render: false });
1819
+ }
1626
1820
  renderStudio();
1627
1821
  remountStudioEditorIfNeeded();
1628
1822
  updateStudioControls();
@@ -1887,6 +2081,10 @@ async function acceptStudioAiChange(filePath) {
1887
2081
  applyStudioCheckState(result.checkState);
1888
2082
  }
1889
2083
  state.studio.files = result.files ?? state.studio.files;
2084
+ state.studio.directories = result.directories ?? state.studio.directories;
2085
+ if (change.path === ".pressshipignore") {
2086
+ await refreshStudioIgnoreState({ files: true, render: false });
2087
+ }
1890
2088
  removeStudioAiChangedFile(change.path);
1891
2089
  if (state.studio.selectedFile?.path === change.path) {
1892
2090
  if (change.status === "deleted") {
@@ -2047,8 +2245,9 @@ function renderStudio() {
2047
2245
  return;
2048
2246
  }
2049
2247
 
2050
- const fileList = state.studio.files.length
2051
- ? renderStudioFileTree(buildStudioFileTree(state.studio.files))
2248
+ const hasExplorerEntries = state.studio.files.length || state.studio.directories.length;
2249
+ const fileList = hasExplorerEntries
2250
+ ? renderStudioFileTree(buildStudioFileTree(state.studio.files, state.studio.directories))
2052
2251
  : `<p class="studio-muted">No editable text files found.</p>`;
2053
2252
  const playgroundPort = state.studio.playgroundUrl ? new URL(state.studio.playgroundUrl).port : "";
2054
2253
  const panels = normalizeStudioPanelState(state.studio.panels);
@@ -2061,30 +2260,37 @@ function renderStudio() {
2061
2260
  <span>${escapeHtml(source)}</span>
2062
2261
  </div>
2063
2262
  <div class="studio-title-actions">
2064
- <button class="studio-layout-button${panels.files ? " is-active" : ""}" type="button" data-action="studio-toggle-files" aria-pressed="${panels.files ? "true" : "false"}" title="${panels.files ? "Hide Explorer" : "Show Explorer"}">
2065
- <span class="dashicons dashicons-align-left" aria-hidden="true"></span>
2066
- </button>
2067
- ${renderStudioPlayButton()}
2068
- <span class="studio-preview-state${state.studio.running ? " is-loading" : state.studio.playgroundUrl ? " is-ready" : ""}">
2069
- <span aria-hidden="true"></span>
2070
- ${escapeHtml(studioPreviewStateLabel())}
2071
- </span>
2072
- <button class="studio-icon-button" type="button" data-action="studio-toggle-terminal" aria-pressed="${state.studio.terminalOpen ? "true" : "false"}" title="${state.studio.terminalOpen ? "Hide terminal" : "Show terminal"}">
2073
- <span class="dashicons dashicons-editor-kitchensink" aria-hidden="true"></span>
2074
- <span>Terminal</span>
2075
- </button>
2076
- <button class="studio-layout-button${panels.sidebar ? " is-active" : ""}" type="button" data-action="studio-toggle-sidebar" aria-pressed="${panels.sidebar ? "true" : "false"}" title="${panels.sidebar ? "Hide Secondary Side Bar" : "Show Secondary Side Bar"}">
2077
- <span class="dashicons dashicons-align-right" aria-hidden="true"></span>
2078
- </button>
2079
- <button class="studio-action-button" type="button" data-action="studio-save" id="studio-save-button" disabled>
2080
- <span class="dashicons dashicons-saved" aria-hidden="true"></span>
2081
- Save
2082
- </button>
2083
- <button class="studio-action-button" type="button" data-action="studio-check" id="studio-check-button" disabled>
2084
- <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
2085
- Check
2086
- </button>
2087
- ${renderStudioThemeToggle()}
2263
+ <div class="studio-toolbar-group studio-toolbar-layout" aria-label="Workbench layout">
2264
+ <button class="studio-layout-button${panels.files ? " is-active" : ""}" type="button" data-action="studio-toggle-files" aria-pressed="${panels.files ? "true" : "false"}" aria-label="${panels.files ? "Hide Explorer" : "Show Explorer"}" title="${panels.files ? "Hide Explorer" : "Show Explorer"}">
2265
+ <span class="dashicons dashicons-align-left" aria-hidden="true"></span>
2266
+ </button>
2267
+ <button class="studio-icon-button studio-compact-button" type="button" data-action="studio-toggle-terminal" aria-pressed="${state.studio.terminalOpen ? "true" : "false"}" aria-label="${state.studio.terminalOpen ? "Hide Terminal" : "Show Terminal"}" title="${state.studio.terminalOpen ? "Hide Terminal" : "Show Terminal"}">
2268
+ <span class="dashicons dashicons-editor-kitchensink" aria-hidden="true"></span>
2269
+ <span>Terminal</span>
2270
+ </button>
2271
+ <button class="studio-layout-button${panels.sidebar ? " is-active" : ""}" type="button" data-action="studio-toggle-sidebar" aria-pressed="${panels.sidebar ? "true" : "false"}" aria-label="${panels.sidebar ? "Hide Secondary Side Bar" : "Show Secondary Side Bar"}" title="${panels.sidebar ? "Hide Secondary Side Bar" : "Show Secondary Side Bar"}">
2272
+ <span class="dashicons dashicons-align-right" aria-hidden="true"></span>
2273
+ </button>
2274
+ </div>
2275
+ <div class="studio-toolbar-group studio-toolbar-run" aria-label="Playground">
2276
+ ${renderStudioPlayButton()}
2277
+ <span class="studio-preview-state${state.studio.running ? " is-loading" : state.studio.playgroundUrl ? " is-ready" : ""}" title="${escapeAttr(studioPreviewStateTitle())}">
2278
+ <span aria-hidden="true"></span>
2279
+ <em>${escapeHtml(studioPreviewStateLabel())}</em>
2280
+ </span>
2281
+ </div>
2282
+ <div class="studio-toolbar-group studio-toolbar-actions" aria-label="Editor actions">
2283
+ <button class="studio-action-button studio-compact-button" type="button" data-action="studio-save" id="studio-save-button" aria-label="${escapeAttr(`Save ${studioSaveShortcutLabel()}`)}" aria-keyshortcuts="${escapeAttr(studioSaveAriaShortcut())}" title="${escapeAttr(`Save ${studioSaveShortcutLabel()}`)}" disabled>
2284
+ <span class="dashicons dashicons-saved" aria-hidden="true"></span>
2285
+ <span>Save</span>
2286
+ </button>
2287
+ <button class="studio-action-button studio-compact-button" type="button" data-action="studio-check" id="studio-check-button" aria-label="Run Plugin Check" title="Run Plugin Check" disabled>
2288
+ <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
2289
+ <span>Check</span>
2290
+ </button>
2291
+ ${renderStudioPackageSizeButton()}
2292
+ ${renderStudioThemeToggle()}
2293
+ </div>
2088
2294
  </div>
2089
2295
  </header>
2090
2296
  <div class="studio-main">
@@ -2191,7 +2397,7 @@ function renderStudioActivityBar(panels) {
2191
2397
  })}
2192
2398
  ${activityButton({
2193
2399
  action: "studio-open-sidebar-tab",
2194
- icon: "dashicons-update",
2400
+ icon: "ps-icon-rocket",
2195
2401
  label: "Release",
2196
2402
  active: panels.sidebar && tab === "release",
2197
2403
  tab: "release"
@@ -2478,6 +2684,68 @@ function renderStudioPlayButton() {
2478
2684
  `;
2479
2685
  }
2480
2686
 
2687
+ function studioSaveShortcutLabel() {
2688
+ return isMac ? "(⌘S)" : "(Ctrl+S)";
2689
+ }
2690
+
2691
+ function studioSaveAriaShortcut() {
2692
+ return isMac ? "Meta+S" : "Control+S";
2693
+ }
2694
+
2695
+ function renderStudioPackageSizeButton() {
2696
+ if (state.studio.scope !== "local") {
2697
+ return "";
2698
+ }
2699
+ const packageSize = state.studio.packageSize ?? createInitialStudioPackageSize();
2700
+ const hasSize = Number.isFinite(packageSize.sizeBytes);
2701
+ const label = packageSize.loading
2702
+ ? "Sizing"
2703
+ : hasSize
2704
+ ? formatStudioBytes(packageSize.sizeBytes)
2705
+ : "Size";
2706
+ const icon = packageSize.loading
2707
+ ? "dashicons-update"
2708
+ : packageSize.error
2709
+ ? "dashicons-warning"
2710
+ : "dashicons-archive";
2711
+ const className = [
2712
+ "studio-action-button",
2713
+ "studio-compact-button",
2714
+ "studio-package-size-button",
2715
+ packageSize.loading ? "is-loading" : "",
2716
+ packageSize.overLimit ? "is-over-limit" : "",
2717
+ packageSize.stale ? "is-stale" : "",
2718
+ packageSize.error ? "has-error" : ""
2719
+ ].filter(Boolean).join(" ");
2720
+
2721
+ return `
2722
+ <button class="${escapeAttr(className)}" type="button" data-action="studio-package-size" id="studio-package-size-button" title="${escapeAttr(studioPackageSizeTitle(packageSize))}" ${packageSize.loading ? "disabled aria-busy=\"true\"" : ""}>
2723
+ <span class="dashicons ${escapeAttr(icon)}" aria-hidden="true"></span>
2724
+ <span>${escapeHtml(label)}</span>
2725
+ </button>
2726
+ `;
2727
+ }
2728
+
2729
+ function studioPackageSizeTitle(packageSize) {
2730
+ if (packageSize.loading) {
2731
+ return "Calculating package size";
2732
+ }
2733
+ if (packageSize.error) {
2734
+ return `Package size failed: ${packageSize.error}`;
2735
+ }
2736
+ if (!Number.isFinite(packageSize.sizeBytes)) {
2737
+ return "Calculate package size";
2738
+ }
2739
+ const limit = formatStudioBytes(packageSize.maxSizeBytes || 10 * 1024 * 1024);
2740
+ const status = packageSize.overLimit ? `Over the ${limit} WordPress.org limit` : `Under the ${limit} WordPress.org limit`;
2741
+ const stale = packageSize.stale ? " Stale: recalculate after recent file or ignore changes." : "";
2742
+ const largest = (packageSize.largestFiles ?? []).slice(0, 3);
2743
+ const largestText = largest.length
2744
+ ? ` Largest: ${largest.map((file) => `${file.path} (${formatStudioBytes(file.sizeBytes)})`).join(", ")}.`
2745
+ : "";
2746
+ return `${formatStudioBytes(packageSize.sizeBytes)} across ${packageSize.fileCount ?? 0} packaged files. ${status}.${largestText}${stale}`;
2747
+ }
2748
+
2481
2749
  function renderStudioThemeToggle() {
2482
2750
  const theme = studioTheme();
2483
2751
  const isLight = theme === "light";
@@ -2494,13 +2762,23 @@ function renderStudioThemeToggle() {
2494
2762
  }
2495
2763
 
2496
2764
  function studioPreviewStateLabel() {
2765
+ if (state.studio.running) {
2766
+ return "Starting";
2767
+ }
2768
+ if (state.studio.playgroundUrl) {
2769
+ return "Ready";
2770
+ }
2771
+ return "Idle";
2772
+ }
2773
+
2774
+ function studioPreviewStateTitle() {
2497
2775
  if (state.studio.running) {
2498
2776
  return "Starting Playground";
2499
2777
  }
2500
2778
  if (state.studio.playgroundUrl) {
2501
2779
  return "Playground ready";
2502
2780
  }
2503
- return "Not started";
2781
+ return "Playground not started";
2504
2782
  }
2505
2783
 
2506
2784
  function studioEditorStatusLabel() {
@@ -2618,22 +2896,133 @@ function toggleStudioTheme() {
2618
2896
  requestAnimationFrame(() => state.studio.editor?.layout?.());
2619
2897
  }
2620
2898
 
2621
- function toggleStudioFolder(folderPath) {
2899
+ async function toggleStudioFolder(folderPath) {
2622
2900
  if (!folderPath) {
2623
2901
  return;
2624
2902
  }
2903
+ const normalized = String(folderPath ?? "").replace(/\/+$/, "");
2625
2904
  captureStudioEditorValue();
2626
- if (state.studio.collapsedFolders.has(folderPath)) {
2627
- state.studio.collapsedFolders.delete(folderPath);
2905
+ const directory = state.studio.directories?.find((entry) => entry.path === normalized);
2906
+ if (directory?.deferred) {
2907
+ const loaded = await loadStudioDirectory(normalized);
2908
+ if (!loaded) {
2909
+ renderStudio();
2910
+ remountStudioEditorIfNeeded();
2911
+ return;
2912
+ }
2913
+ if (studioFolderIsIgnored(normalized)) {
2914
+ state.studio.expandedIgnoredFolders.add(normalized);
2915
+ } else {
2916
+ state.studio.collapsedFolders.delete(normalized);
2917
+ }
2918
+ renderStudio();
2919
+ remountStudioEditorIfNeeded();
2920
+ return;
2921
+ }
2922
+
2923
+ if (studioFolderIsIgnored(normalized)) {
2924
+ if (state.studio.expandedIgnoredFolders.has(normalized)) {
2925
+ state.studio.expandedIgnoredFolders.delete(normalized);
2926
+ } else {
2927
+ state.studio.expandedIgnoredFolders.add(normalized);
2928
+ }
2929
+ renderStudio();
2930
+ remountStudioEditorIfNeeded();
2931
+ return;
2932
+ }
2933
+
2934
+ if (state.studio.collapsedFolders.has(normalized)) {
2935
+ state.studio.collapsedFolders.delete(normalized);
2628
2936
  } else {
2629
- state.studio.collapsedFolders.add(folderPath);
2937
+ state.studio.collapsedFolders.add(normalized);
2938
+ }
2939
+ renderStudio();
2940
+ remountStudioEditorIfNeeded();
2941
+ }
2942
+
2943
+ async function loadStudioDirectory(folderPath) {
2944
+ const normalized = String(folderPath ?? "").replace(/^\.\/+/, "").replace(/\/+$/, "");
2945
+ if (!normalized || state.studio.scope !== "local" || !state.studio.id) {
2946
+ return false;
2630
2947
  }
2948
+ if (state.studio.loadingFolders.has(normalized)) {
2949
+ return false;
2950
+ }
2951
+
2952
+ state.studio.loadingFolders.add(normalized);
2631
2953
  renderStudio();
2632
2954
  remountStudioEditorIfNeeded();
2955
+ try {
2956
+ const result = await api(
2957
+ `/api/plugins/local/${encodeURIComponent(state.studio.id)}/files/directory?path=${encodeURIComponent(normalized)}`
2958
+ );
2959
+ mergeStudioExplorerEntries(result.files ?? [], result.directories ?? []);
2960
+ const directory = state.studio.directories.find((entry) => entry.path === normalized);
2961
+ if (directory) {
2962
+ directory.deferred = false;
2963
+ }
2964
+ return true;
2965
+ } catch (error) {
2966
+ appendStudioTerminal(`Folder load failed for ${normalized}: ${error.message}`, "error");
2967
+ notice(error.message, "error");
2968
+ return false;
2969
+ } finally {
2970
+ state.studio.loadingFolders.delete(normalized);
2971
+ }
2972
+ }
2973
+
2974
+ function mergeStudioExplorerEntries(files, directories) {
2975
+ state.studio.files = mergeStudioEntriesByPath(state.studio.files, files);
2976
+ state.studio.directories = mergeStudioEntriesByPath(state.studio.directories, directories);
2633
2977
  }
2634
2978
 
2635
- function buildStudioFileTree(files) {
2979
+ function mergeStudioEntriesByPath(existingEntries = [], incomingEntries = []) {
2980
+ const merged = new Map((existingEntries ?? []).map((entry) => [entry.path, entry]));
2981
+ for (const entry of incomingEntries ?? []) {
2982
+ if (!entry?.path) {
2983
+ continue;
2984
+ }
2985
+ merged.set(entry.path, {
2986
+ ...(merged.get(entry.path) ?? {}),
2987
+ ...entry
2988
+ });
2989
+ }
2990
+ return Array.from(merged.values());
2991
+ }
2992
+
2993
+ function studioFolderIsIgnored(folderPath) {
2994
+ const normalized = String(folderPath ?? "").replace(/\/+$/, "");
2995
+ if (!normalized) {
2996
+ return false;
2997
+ }
2998
+ const directory = state.studio.directories?.find((entry) => entry.path === normalized);
2999
+ return Boolean(directory?.ignored || studioIgnoredFilesForPrefix(normalized).length);
3000
+ }
3001
+
3002
+ function buildStudioFileTree(files, directories = []) {
2636
3003
  const root = { type: "folder", name: "", path: "", children: new Map() };
3004
+ for (const directory of directories) {
3005
+ const parts = directory.path.split("/").filter(Boolean);
3006
+ let node = root;
3007
+ let currentPath = "";
3008
+ parts.forEach((part) => {
3009
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
3010
+ if (!node.children.has(part)) {
3011
+ node.children.set(part, {
3012
+ type: "folder",
3013
+ name: part,
3014
+ path: currentPath,
3015
+ children: new Map()
3016
+ });
3017
+ }
3018
+ node = node.children.get(part);
3019
+ });
3020
+ node.deferred = Boolean(directory.deferred);
3021
+ node.ignored = Boolean(directory.ignored);
3022
+ node.ignoredBy = directory.ignoredBy ?? "";
3023
+ node.hardIgnored = Boolean(directory.hardIgnored);
3024
+ }
3025
+
2637
3026
  for (const file of files) {
2638
3027
  const parts = file.path.split("/").filter(Boolean);
2639
3028
  let node = root;
@@ -2677,19 +3066,48 @@ function renderStudioTreeChildren(children, depth) {
2677
3066
 
2678
3067
  function renderStudioTreeNode(node, depth) {
2679
3068
  if (node.type === "folder") {
2680
- const collapsed = state.studio.collapsedFolders.has(node.path);
3069
+ const deferred = Boolean(node.deferred);
3070
+ const loadingFolder = state.studio.loadingFolders?.has(node.path);
3071
+ const nodeIgnored = Boolean(node.ignored);
2681
3072
  const containsCurrent = Boolean(
2682
3073
  state.studio.selectedFile?.path && state.studio.selectedFile.path.startsWith(`${node.path}/`)
2683
3074
  );
2684
3075
  const containsAiChange = studioAiChangedFilesForPrefix(node.path).length > 0;
3076
+ const ignoredCount = studioIgnoredFilesForPrefix(node.path).length;
3077
+ const ignoredFolder = nodeIgnored || ignoredCount > 0;
3078
+ const collapsed = deferred ||
3079
+ (ignoredFolder && !state.studio.expandedIgnoredFolders.has(node.path)) ||
3080
+ state.studio.collapsedFolders.has(node.path);
3081
+ const ignorePattern = studioFolderIgnorePattern(node.path);
3082
+ const hasExactIgnore = studioHasIgnorePattern(ignorePattern);
3083
+ const busy = state.studio.ignoreBusyPattern === ignorePattern;
3084
+ const ignoredTitle = node.ignoredBy
3085
+ ? `Ignored by ${node.ignoredBy}`
3086
+ : ignoredCount
3087
+ ? `${ignoredCount} ignored file${ignoredCount === 1 ? "" : "s"} inside`
3088
+ : "Ignored";
2685
3089
  return `
2686
- <div class="studio-tree-folder${containsCurrent ? " has-current" : ""}${containsAiChange ? " has-ai-changes" : ""}" role="treeitem" aria-expanded="${collapsed ? "false" : "true"}">
2687
- <button type="button" class="studio-tree-row studio-tree-folder-row" data-action="studio-toggle-folder" data-folder="${escapeAttr(node.path)}" style="--depth:${depth}">
2688
- <span class="dashicons ${collapsed ? "dashicons-arrow-right-alt2" : "dashicons-arrow-down-alt2"} studio-tree-arrow" aria-hidden="true"></span>
2689
- <span class="dashicons ${collapsed ? "dashicons-category" : "dashicons-open-folder"} studio-tree-icon" aria-hidden="true"></span>
2690
- <span class="studio-tree-label">${escapeHtml(node.name)}</span>
2691
- ${containsAiChange ? `<span class="studio-tree-ai-badge" title="AI patches inside this folder">AI</span>` : ""}
2692
- </button>
3090
+ <div class="studio-tree-folder${containsCurrent ? " has-current" : ""}${containsAiChange ? " has-ai-changes" : ""}${ignoredFolder ? " is-ignored" : ""}" role="treeitem" aria-expanded="${collapsed ? "false" : "true"}">
3091
+ <div class="studio-tree-row studio-tree-folder-row${ignoredFolder ? " has-ignored" : ""}${loadingFolder ? " is-loading" : ""}" style="--depth:${depth}">
3092
+ <button type="button" class="studio-tree-main" data-action="studio-toggle-folder" data-folder="${escapeAttr(node.path)}" ${loadingFolder ? "aria-busy=\"true\"" : ""}>
3093
+ <span class="dashicons ${loadingFolder ? "dashicons-update" : collapsed ? "dashicons-arrow-right-alt2" : "dashicons-arrow-down-alt2"} studio-tree-arrow" aria-hidden="true"></span>
3094
+ <span class="dashicons ${deferred || collapsed ? "dashicons-category" : "dashicons-open-folder"} studio-tree-icon" aria-hidden="true"></span>
3095
+ <span class="studio-tree-label">${escapeHtml(node.name)}</span>
3096
+ </button>
3097
+ <span class="studio-tree-badges">
3098
+ ${containsAiChange ? `<span class="studio-tree-ai-badge" title="AI patches inside this folder">AI</span>` : ""}
3099
+ </span>
3100
+ ${node.hardIgnored || (nodeIgnored && !hasExactIgnore)
3101
+ ? renderStudioReadonlyIgnoreAction(node.ignoredBy ?? ignoredTitle)
3102
+ : renderStudioIgnoreAction({
3103
+ action: hasExactIgnore ? "studio-unignore-rule" : "studio-ignore-folder",
3104
+ label: hasExactIgnore ? "Unignore folder" : "Ignore folder",
3105
+ icon: hasExactIgnore ? "dashicons-hidden" : "dashicons-visibility",
3106
+ path: node.path,
3107
+ pattern: ignorePattern,
3108
+ busy
3109
+ })}
3110
+ </div>
2693
3111
  ${collapsed ? "" : `<div role="group">${renderStudioTreeChildren(node.children, depth + 1)}</div>`}
2694
3112
  </div>
2695
3113
  `;
@@ -2698,6 +3116,12 @@ function renderStudioTreeNode(node, depth) {
2698
3116
  const current = node.path === state.studio.selectedFile?.path;
2699
3117
  const checkCounts = studioCheckCountsForPath(node.path);
2700
3118
  const aiChange = studioAiChangedFile(node.path);
3119
+ const ignored = Boolean(node.file?.ignored);
3120
+ const ignoredBy = node.file?.ignoredBy;
3121
+ const hardIgnored = Boolean(node.file?.hardIgnored);
3122
+ const exactPattern = studioFileIgnorePattern(node.path);
3123
+ const hasExactIgnore = studioHasIgnorePattern(exactPattern);
3124
+ const busy = state.studio.ignoreBusyPattern === exactPattern;
2701
3125
  const checkBadge = checkCounts.total
2702
3126
  ? `<span class="studio-tree-check-badge${checkCounts.error ? " has-errors" : ""}" title="${escapeAttr(formatCheckCounts(checkCounts))}">${escapeHtml(String(checkCounts.total))}</span>`
2703
3127
  : "";
@@ -2705,11 +3129,43 @@ function renderStudioTreeNode(node, depth) {
2705
3129
  ? `<span class="studio-tree-ai-badge" title="${escapeAttr(`AI proposed ${aiChange.status} patch`)}">AI</span>`
2706
3130
  : "";
2707
3131
  return `
2708
- <button type="button" role="treeitem" class="studio-tree-row studio-tree-file-row${current ? " is-current" : ""}${checkCounts.error ? " has-check-errors" : ""}${aiChange ? " has-ai-changes" : ""}" data-action="studio-file" data-path="${escapeAttr(node.path)}" style="--depth:${depth}">
2709
- <span class="studio-tree-indent" aria-hidden="true"></span>
2710
- <span class="dashicons ${studioFileIcon(node.path)} studio-tree-icon" aria-hidden="true"></span>
2711
- <span class="studio-tree-label">${escapeHtml(node.name)}</span>
3132
+ <div role="treeitem" class="studio-tree-row studio-tree-file-row${current ? " is-current" : ""}${checkCounts.error ? " has-check-errors" : ""}${aiChange ? " has-ai-changes" : ""}${ignored ? " is-ignored" : ""}" style="--depth:${depth}">
3133
+ <button type="button" class="studio-tree-main" data-action="studio-file" data-path="${escapeAttr(node.path)}">
3134
+ <span class="studio-tree-indent" aria-hidden="true"></span>
3135
+ <span class="dashicons ${studioFileIcon(node.path)} studio-tree-icon" aria-hidden="true"></span>
3136
+ <span class="studio-tree-label">${escapeHtml(node.name)}</span>
3137
+ </button>
2712
3138
  <span class="studio-tree-badges">${aiBadge}${checkBadge}</span>
3139
+ ${hardIgnored || (ignored && !hasExactIgnore)
3140
+ ? renderStudioReadonlyIgnoreAction(ignoredBy ?? "Pressship package rules")
3141
+ : renderStudioIgnoreAction({
3142
+ action: hasExactIgnore ? "studio-unignore-rule" : "studio-ignore-file",
3143
+ label: hasExactIgnore ? "Unignore file" : "Ignore file",
3144
+ icon: hasExactIgnore ? "dashicons-hidden" : "dashicons-visibility",
3145
+ path: node.path,
3146
+ pattern: exactPattern,
3147
+ busy
3148
+ })}
3149
+ </div>
3150
+ `;
3151
+ }
3152
+
3153
+ function renderStudioReadonlyIgnoreAction(reason) {
3154
+ const title = reason ? `Ignored by ${reason}` : "Ignored";
3155
+ return `
3156
+ <span class="studio-tree-action is-readonly" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}">
3157
+ <span class="dashicons dashicons-hidden" aria-hidden="true"></span>
3158
+ </span>
3159
+ `;
3160
+ }
3161
+
3162
+ function renderStudioIgnoreAction({ action, label, icon, path, pattern, busy }) {
3163
+ if (!pattern || path === ".pressshipignore" || state.studio.scope !== "local") {
3164
+ return "";
3165
+ }
3166
+ return `
3167
+ <button type="button" class="studio-tree-action${busy ? " is-busy" : ""}" data-action="${escapeAttr(action)}" data-path="${escapeAttr(path ?? "")}" data-pattern="${escapeAttr(pattern)}" title="${escapeAttr(label)}" aria-label="${escapeAttr(label)}" ${busy ? "disabled aria-busy=\"true\"" : ""}>
3168
+ <span class="dashicons ${busy ? "dashicons-update" : icon}" aria-hidden="true"></span>
2713
3169
  </button>
2714
3170
  `;
2715
3171
  }
@@ -2738,7 +3194,7 @@ function renderStudioSidebarTabs(activeTab) {
2738
3194
  AI Helper
2739
3195
  </button>
2740
3196
  <button class="ps-segmented-option${activeTab === "release" ? " is-active" : ""}" type="button" role="tab" aria-selected="${activeTab === "release"}" data-action="studio-sidebar-tab" data-tab="release">
2741
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
3197
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
2742
3198
  Release
2743
3199
  </button>
2744
3200
  </div>
@@ -3328,6 +3784,135 @@ function appendStudioTerminal(message, tone = "muted") {
3328
3784
  }
3329
3785
  }
3330
3786
 
3787
+ function appendStudioCliCommand(command) {
3788
+ if (state.activeView !== "studio" || !command || !Array.isArray(state.studio.terminal)) {
3789
+ return;
3790
+ }
3791
+ state.studio.terminalOpen = true;
3792
+ appendStudioTerminal(`$ ${command}`, "command");
3793
+ }
3794
+
3795
+ function quoteCliArg(value) {
3796
+ const text = String(value ?? "").trim();
3797
+ if (!text) {
3798
+ return "''";
3799
+ }
3800
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(text)
3801
+ ? text
3802
+ : `'${text.replace(/'/g, "'\\''")}'`;
3803
+ }
3804
+
3805
+ function studioCliCommand(parts) {
3806
+ return [STUDIO_CLI_PREFIX, ...parts].filter(Boolean).join(" ");
3807
+ }
3808
+
3809
+ function studioOpenCliCommand() {
3810
+ const parts = ["studio"];
3811
+ const host = window.location.hostname;
3812
+ const port = window.location.port;
3813
+ if (host && !["127.0.0.1", "localhost"].includes(host)) {
3814
+ parts.push("--host", quoteCliArg(host));
3815
+ }
3816
+ if (port && port !== "9477") {
3817
+ parts.push("--port", quoteCliArg(port));
3818
+ }
3819
+ return studioCliCommand(parts);
3820
+ }
3821
+
3822
+ function localPluginForCli(localId = state.studio.id) {
3823
+ return state.local.find((plugin) => plugin.id === localId) ??
3824
+ (state.studio.scope === "local" && state.studio.id === localId ? state.studio.plugin : null);
3825
+ }
3826
+
3827
+ function localPluginCliTarget(localId = state.studio.id) {
3828
+ const plugin = localPluginForCli(localId);
3829
+ return quoteCliArg(plugin?.path || plugin?.slug || localId || ".");
3830
+ }
3831
+
3832
+ function studioCliIgnoreFlags(localId = state.studio.id) {
3833
+ if (localId !== state.studio.id) {
3834
+ return [];
3835
+ }
3836
+ return studioIgnorePatterns().flatMap((pattern) => ["--ignore", quoteCliArg(pattern)]);
3837
+ }
3838
+
3839
+ function studioPublishCliCommand(localId, action, options = {}) {
3840
+ const normalizedAction = ["submit", "release"].includes(action) ? action : "auto";
3841
+ const parts = ["publish", localPluginCliTarget(localId)];
3842
+ if (normalizedAction !== "auto") {
3843
+ parts.push(`--${normalizedAction}`);
3844
+ }
3845
+ if (options.dryRun) {
3846
+ parts.push("--dry-run");
3847
+ }
3848
+ parts.push(...studioCliIgnoreFlags(localId), "--yes");
3849
+ return studioCliCommand(parts);
3850
+ }
3851
+
3852
+ function studioPlaygroundCliCommand(input) {
3853
+ const target = input.scope === "local" ? localPluginCliTarget(input.id) : quoteCliArg(input.id);
3854
+ const settings = state.settings ?? {};
3855
+ const parts = ["demo", target, "--reset", "--skip-browser"];
3856
+ if (input.wpVersion && input.wpVersion !== "latest") {
3857
+ parts.push("--wp", quoteCliArg(input.wpVersion));
3858
+ }
3859
+ if (settings.playgroundDatabaseMode && settings.playgroundDatabaseMode !== "auto") {
3860
+ parts.push("--database", quoteCliArg(settings.playgroundDatabaseMode));
3861
+ }
3862
+ if (settings.playgroundDatabaseMode === "mysql") {
3863
+ parts.push(
3864
+ "--mysql-host",
3865
+ quoteCliArg(settings.playgroundMysqlHost ?? "127.0.0.1"),
3866
+ "--mysql-port",
3867
+ quoteCliArg(settings.playgroundMysqlPort ?? 3306),
3868
+ "--mysql-user",
3869
+ quoteCliArg(settings.playgroundMysqlUser ?? "root"),
3870
+ "--mysql-database-prefix",
3871
+ quoteCliArg(settings.playgroundMysqlDatabasePrefix ?? "pressship_playground")
3872
+ );
3873
+ if (settings.playgroundMysqlPassword) {
3874
+ parts.push("--mysql-password", quoteCliArg(settings.playgroundMysqlPassword));
3875
+ }
3876
+ }
3877
+ return studioCliCommand(parts);
3878
+ }
3879
+
3880
+ function studioCliCommandForJob(input) {
3881
+ if (state.activeView !== "studio") {
3882
+ return "";
3883
+ }
3884
+ switch (input?.type) {
3885
+ case "clone": {
3886
+ const parts = ["get", quoteCliArg(input.slug)];
3887
+ if (input.destination) {
3888
+ parts.push(quoteCliArg(input.destination));
3889
+ }
3890
+ return studioCliCommand(parts);
3891
+ }
3892
+ case "play":
3893
+ return studioPlaygroundCliCommand(input);
3894
+ case "check":
3895
+ return studioCliCommand([
3896
+ "verify",
3897
+ localPluginCliTarget(input.localId),
3898
+ "--skip-readme-validator",
3899
+ ...studioCliIgnoreFlags(input.localId)
3900
+ ]);
3901
+ case "dry-run-publish":
3902
+ return studioPublishCliCommand(input.localId, input.action, { dryRun: true });
3903
+ case "confirm-publish": {
3904
+ const dryRun = state.studio.release?.dryRun;
3905
+ if (!dryRun?.approvalId || dryRun.approvalId !== input.approvalId) {
3906
+ return "";
3907
+ }
3908
+ const action = dryRun?.route?.action ?? "auto";
3909
+ return state.studio.id ? studioPublishCliCommand(state.studio.id, action) : "";
3910
+ }
3911
+ default:
3912
+ return "";
3913
+ }
3914
+ }
3915
+
3331
3916
  function selectedStudioAiAssistant() {
3332
3917
  return state.settings?.aiAssistant ?? "none";
3333
3918
  }
@@ -3393,6 +3978,287 @@ function applyStudioCheckState(checkState) {
3393
3978
  state.studio.checkRanAt = checkState.checkedAt ?? null;
3394
3979
  }
3395
3980
 
3981
+ function applyStudioIgnoreState(ignoreState) {
3982
+ state.studio.ignoreState = {
3983
+ ...createInitialStudioIgnoreState(),
3984
+ ...(ignoreState ?? {})
3985
+ };
3986
+ }
3987
+
3988
+ function applyStudioPackageSize(packageSize) {
3989
+ if (packageSize?.status === "calculating") {
3990
+ state.studio.packageSize = {
3991
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
3992
+ loading: true,
3993
+ error: "",
3994
+ stale: false
3995
+ };
3996
+ scheduleStudioPackageSizePoll();
3997
+ return;
3998
+ }
3999
+ if (packageSize?.status === "error") {
4000
+ state.studio.packageSize = {
4001
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4002
+ loading: false,
4003
+ error: packageSize.error ?? "Package size could not be calculated.",
4004
+ calculatedAt: packageSize.calculatedAt ?? new Date().toISOString(),
4005
+ stale: false
4006
+ };
4007
+ return;
4008
+ }
4009
+
4010
+ state.studio.packageSize = {
4011
+ ...createInitialStudioPackageSize(),
4012
+ ...(packageSize ?? {}),
4013
+ loading: false,
4014
+ error: "",
4015
+ calculatedAt: packageSize?.calculatedAt ?? new Date().toISOString(),
4016
+ stale: false
4017
+ };
4018
+ }
4019
+
4020
+ function markStudioPackageSizeStale() {
4021
+ const packageSize = state.studio.packageSize;
4022
+ if (!packageSize || !packageSize.calculatedAt) {
4023
+ return;
4024
+ }
4025
+ state.studio.packageSize = {
4026
+ ...packageSize,
4027
+ stale: true
4028
+ };
4029
+ }
4030
+
4031
+ async function refreshStudioPackageSize(options = {}) {
4032
+ if (state.studio.scope !== "local" || !state.studio.id) {
4033
+ return;
4034
+ }
4035
+ if (state.studio.packageSize?.loading && !options.poll) {
4036
+ return;
4037
+ }
4038
+ if (!options.force && state.studio.packageSize?.calculatedAt) {
4039
+ return;
4040
+ }
4041
+
4042
+ const localId = state.studio.id;
4043
+ if (options.notify) {
4044
+ appendStudioCliCommand(studioCliCommand([
4045
+ "pack",
4046
+ localPluginCliTarget(localId),
4047
+ "--no-verify",
4048
+ ...studioCliIgnoreFlags(localId),
4049
+ "--json"
4050
+ ]));
4051
+ }
4052
+ state.studio.packageSize = {
4053
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4054
+ loading: true,
4055
+ error: ""
4056
+ };
4057
+ if (options.render !== false) {
4058
+ renderStudio();
4059
+ remountStudioEditorIfNeeded();
4060
+ }
4061
+
4062
+ try {
4063
+ const packageSize = await api(`/api/plugins/local/${encodeURIComponent(localId)}/package-size`);
4064
+ if (state.studio.id !== localId) {
4065
+ return;
4066
+ }
4067
+ applyStudioPackageSize(packageSize);
4068
+ if (options.notify && packageSize.status !== "calculating") {
4069
+ if (packageSize.status === "error") {
4070
+ notice(packageSize.error ?? "Package size could not be calculated.", "error");
4071
+ } else {
4072
+ notice(
4073
+ packageSize.overLimit
4074
+ ? `Package is ${formatStudioBytes(packageSize.sizeBytes)}, over the WordPress.org 10 MB limit.`
4075
+ : `Package is ${formatStudioBytes(packageSize.sizeBytes)}.`,
4076
+ packageSize.overLimit ? "warning" : "success"
4077
+ );
4078
+ }
4079
+ }
4080
+ } catch (error) {
4081
+ if (state.studio.id !== localId) {
4082
+ return;
4083
+ }
4084
+ state.studio.packageSize = {
4085
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4086
+ loading: false,
4087
+ error: error.message
4088
+ };
4089
+ if (options.notify) {
4090
+ notice(error.message, "error");
4091
+ }
4092
+ } finally {
4093
+ if (state.studio.id === localId && options.render !== false) {
4094
+ renderStudio();
4095
+ remountStudioEditorIfNeeded();
4096
+ updateStudioControls();
4097
+ }
4098
+ }
4099
+ }
4100
+
4101
+ function scheduleStudioPackageSizePoll() {
4102
+ if (studioPackageSizePollTimer) {
4103
+ return;
4104
+ }
4105
+ studioPackageSizePollTimer = window.setTimeout(() => {
4106
+ studioPackageSizePollTimer = null;
4107
+ void refreshStudioPackageSize({ force: true, render: true, poll: true });
4108
+ }, 1200);
4109
+ }
4110
+
4111
+ async function refreshStudioIgnoreState(options = {}) {
4112
+ if (state.studio.scope !== "local" || !state.studio.id) {
4113
+ return;
4114
+ }
4115
+
4116
+ const localId = state.studio.id;
4117
+ state.studio.ignoreLoading = true;
4118
+ state.studio.ignoreError = "";
4119
+ if (options.render !== false) {
4120
+ updateStudioSidebar();
4121
+ }
4122
+ try {
4123
+ const requests = [
4124
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`)
4125
+ ];
4126
+ if (options.files) {
4127
+ requests.push(api(`/api/plugins/local/${encodeURIComponent(localId)}/files`));
4128
+ }
4129
+ const [ignoreState, filesResult] = await Promise.all(requests);
4130
+ applyStudioIgnoreState(ignoreState);
4131
+ if (filesResult) {
4132
+ state.studio.files = filesResult.files ?? state.studio.files;
4133
+ state.studio.directories = filesResult.directories ?? state.studio.directories;
4134
+ }
4135
+ } catch (error) {
4136
+ state.studio.ignoreError = error.message;
4137
+ } finally {
4138
+ state.studio.ignoreLoading = false;
4139
+ state.studio.ignoreBusyPattern = "";
4140
+ state.studio.ignoreBusyPath = "";
4141
+ if (options.render !== false) {
4142
+ renderStudio();
4143
+ remountStudioEditorIfNeeded();
4144
+ updateStudioSidebar();
4145
+ updateStudioControls();
4146
+ }
4147
+ }
4148
+ }
4149
+
4150
+ function studioIgnorePatterns() {
4151
+ return state.studio.ignoreState?.patterns ?? [];
4152
+ }
4153
+
4154
+ function studioHasIgnorePattern(pattern) {
4155
+ return studioIgnorePatterns().includes(pattern);
4156
+ }
4157
+
4158
+ function studioFileIgnorePattern(filePath) {
4159
+ return String(filePath ?? "").replace(/^\.\/+/, "");
4160
+ }
4161
+
4162
+ function studioFolderIgnorePattern(folderPath) {
4163
+ const normalized = String(folderPath ?? "").replace(/^\.\/+/, "").replace(/\/+$/, "");
4164
+ return normalized ? `${normalized}/**` : "";
4165
+ }
4166
+
4167
+ function studioIgnoredFilesForPrefix(prefix) {
4168
+ const normalized = String(prefix ?? "").replace(/\/+$/, "");
4169
+ if (!normalized) {
4170
+ return [];
4171
+ }
4172
+
4173
+ const files = [];
4174
+ const seen = new Set();
4175
+ const addFile = (file) => {
4176
+ const filePath = String(file?.path ?? "");
4177
+ if (!filePath || seen.has(filePath)) {
4178
+ return;
4179
+ }
4180
+ if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
4181
+ seen.add(filePath);
4182
+ files.push(file);
4183
+ }
4184
+ };
4185
+
4186
+ for (const file of state.studio.ignoreState?.ignoredFiles ?? []) {
4187
+ addFile(file);
4188
+ }
4189
+ for (const file of state.studio.files ?? []) {
4190
+ if (file?.ignored) {
4191
+ addFile(file);
4192
+ }
4193
+ }
4194
+
4195
+ return files;
4196
+ }
4197
+
4198
+ async function addStudioIgnoreRule(pattern, options = {}) {
4199
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
4200
+ return;
4201
+ }
4202
+
4203
+ state.studio.ignoreBusyPattern = pattern;
4204
+ state.studio.ignoreBusyPath = options.path ?? "";
4205
+ updateStudioSidebar();
4206
+ try {
4207
+ const ignoreState = await api(`/api/plugins/local/${encodeURIComponent(state.studio.id)}/ignore-rules`, {
4208
+ method: "POST",
4209
+ body: { pattern }
4210
+ });
4211
+ applyStudioIgnoreState(ignoreState);
4212
+ markStudioPackageSizeStale();
4213
+ await refreshStudioIgnoreState({ files: true, render: false });
4214
+ if (state.studio.selectedFile?.path === ".pressshipignore") {
4215
+ await syncSelectedStudioFileFromDisk({ reason: "ignore-rule", reportErrors: true });
4216
+ }
4217
+ appendStudioTerminal(`Ignored ${options.label ?? pattern}.`, "success");
4218
+ } catch (error) {
4219
+ appendStudioTerminal(error.message, "error");
4220
+ notice(error.message, "error");
4221
+ } finally {
4222
+ state.studio.ignoreBusyPattern = "";
4223
+ state.studio.ignoreBusyPath = "";
4224
+ renderStudio();
4225
+ remountStudioEditorIfNeeded();
4226
+ updateStudioSidebar();
4227
+ updateStudioControls();
4228
+ }
4229
+ }
4230
+
4231
+ async function removeStudioIgnoreRule(pattern) {
4232
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
4233
+ return;
4234
+ }
4235
+
4236
+ state.studio.ignoreBusyPattern = pattern;
4237
+ updateStudioSidebar();
4238
+ try {
4239
+ const ignoreState = await api(`/api/plugins/local/${encodeURIComponent(state.studio.id)}/ignore-rules`, {
4240
+ method: "DELETE",
4241
+ body: { pattern }
4242
+ });
4243
+ applyStudioIgnoreState(ignoreState);
4244
+ markStudioPackageSizeStale();
4245
+ await refreshStudioIgnoreState({ files: true, render: false });
4246
+ if (state.studio.selectedFile?.path === ".pressshipignore") {
4247
+ await syncSelectedStudioFileFromDisk({ reason: "ignore-rule", reportErrors: true });
4248
+ }
4249
+ appendStudioTerminal(`Removed ignore rule ${pattern}.`, "success");
4250
+ } catch (error) {
4251
+ appendStudioTerminal(error.message, "error");
4252
+ notice(error.message, "error");
4253
+ } finally {
4254
+ state.studio.ignoreBusyPattern = "";
4255
+ renderStudio();
4256
+ remountStudioEditorIfNeeded();
4257
+ updateStudioSidebar();
4258
+ updateStudioControls();
4259
+ }
4260
+ }
4261
+
3396
4262
  function appendStudioAiMessage(role, text, tone = "muted") {
3397
4263
  state.studio.aiMessages.push({
3398
4264
  role,
@@ -3728,6 +4594,16 @@ function chooseInitialStudioFile(files, slug) {
3728
4594
  );
3729
4595
  }
3730
4596
 
4597
+ function canSaveStudioFile() {
4598
+ return Boolean(
4599
+ state.studio.scope === "local" &&
4600
+ state.studio.id &&
4601
+ !state.studio.readOnly &&
4602
+ state.studio.selectedFile &&
4603
+ state.studio.dirty
4604
+ );
4605
+ }
4606
+
3731
4607
  function updateStudioControls() {
3732
4608
  const canRun = Boolean(state.studio.id) && !state.studio.loading && !state.studio.running;
3733
4609
  const canCheck =
@@ -3749,12 +4625,17 @@ function updateStudioControls() {
3749
4625
  }
3750
4626
  if (studioCheck) {
3751
4627
  studioCheck.disabled = !canCheck;
4628
+ studioCheck.setAttribute("aria-label", state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check");
4629
+ studioCheck.title = state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check";
3752
4630
  studioCheck.innerHTML = state.studio.checking
3753
- ? `<span class="dashicons dashicons-update" aria-hidden="true"></span> Checking…`
3754
- : `<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span> ${state.studio.checkSummary ? "Re-check" : "Check"}`;
4631
+ ? `<span class="dashicons dashicons-update" aria-hidden="true"></span><span>Checking</span>`
4632
+ : `<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><span>${state.studio.checkSummary ? "Re-check" : "Check"}</span>`;
3755
4633
  }
3756
4634
  if (studioSave) {
3757
- studioSave.disabled = state.studio.readOnly || !state.studio.selectedFile || !state.studio.dirty;
4635
+ studioSave.disabled = !canSaveStudioFile();
4636
+ studioSave.title = `Save ${studioSaveShortcutLabel()}`;
4637
+ studioSave.setAttribute("aria-label", `Save ${studioSaveShortcutLabel()}`);
4638
+ studioSave.setAttribute("aria-keyshortcuts", studioSaveAriaShortcut());
3758
4639
  }
3759
4640
  updateStudioAiControls();
3760
4641
  }
@@ -3846,6 +4727,11 @@ async function mountStudioEditor(content) {
3846
4727
  scrollBeyondLastLine: false
3847
4728
  });
3848
4729
  state.studio.editorKind = "monaco";
4730
+ state.studio.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
4731
+ if (canSaveStudioFile()) {
4732
+ void saveStudioFile();
4733
+ }
4734
+ });
3849
4735
  state.studio.editor.onDidChangeModelContent(() => {
3850
4736
  state.studio.draftContent = getStudioEditorValue();
3851
4737
  state.studio.dirty = state.studio.draftContent !== state.studio.fileContent;
@@ -4057,6 +4943,7 @@ function getStudioEditorValue() {
4057
4943
 
4058
4944
  function ensureMonaco() {
4059
4945
  if (window.monaco) {
4946
+ configurePressshipMonaco(window.monaco);
4060
4947
  return Promise.resolve(window.monaco);
4061
4948
  }
4062
4949
  if (monacoPromise) {
@@ -4072,7 +4959,10 @@ function ensureMonaco() {
4072
4959
  vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs"
4073
4960
  }
4074
4961
  });
4075
- window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
4962
+ window.require(["vs/editor/editor.main"], () => {
4963
+ configurePressshipMonaco(window.monaco);
4964
+ resolve(window.monaco);
4965
+ }, reject);
4076
4966
  };
4077
4967
  script.onerror = () => reject(new Error("Could not load Monaco Editor."));
4078
4968
  document.head.appendChild(script);
@@ -4081,7 +4971,132 @@ function ensureMonaco() {
4081
4971
  return monacoPromise;
4082
4972
  }
4083
4973
 
4974
+ function configurePressshipMonaco(monaco) {
4975
+ if (!monaco || monacoConfigured) {
4976
+ return;
4977
+ }
4978
+ monacoConfigured = true;
4979
+ registerWordPressReadmeLanguage(monaco);
4980
+ definePressshipMonacoThemes(monaco);
4981
+ }
4982
+
4983
+ function registerWordPressReadmeLanguage(monaco) {
4984
+ const languageId = "wordpress-readme";
4985
+ if (!monaco.languages.getLanguages().some((language) => language.id === languageId)) {
4986
+ monaco.languages.register({
4987
+ id: languageId,
4988
+ aliases: ["WordPress Readme", "wordpress-readme"],
4989
+ extensions: [".txt"],
4990
+ filenames: ["readme.txt"]
4991
+ });
4992
+ }
4993
+
4994
+ monaco.languages.setLanguageConfiguration(languageId, {
4995
+ brackets: [
4996
+ ["[", "]"],
4997
+ ["(", ")"],
4998
+ ["`", "`"]
4999
+ ],
5000
+ autoClosingPairs: [
5001
+ { open: "`", close: "`" },
5002
+ { open: "[", close: "]" },
5003
+ { open: "(", close: ")" }
5004
+ ],
5005
+ surroundingPairs: [
5006
+ { open: "`", close: "`" },
5007
+ { open: "*", close: "*" },
5008
+ { open: "[", close: "]" },
5009
+ { open: "(", close: ")" }
5010
+ ],
5011
+ wordPattern: /(-?\d+(?:\.\d+)*)|([^\s`~!@#$%^&*()=+[{\]}\\|;:'",.<>/?]+)/g
5012
+ });
5013
+
5014
+ monaco.languages.setMonarchTokensProvider(languageId, {
5015
+ defaultToken: "wp-readme.text",
5016
+ tokenizer: {
5017
+ root: [
5018
+ [/^\s*={3}\s*.*?\s*={3}\s*$/, "wp-readme.title"],
5019
+ [/^\s*={2}\s*.*?\s*={2}\s*$/, "wp-readme.section"],
5020
+ [/^\s*=\s*.*?\s*=\s*$/, "wp-readme.subsection"],
5021
+ [/^(\s*)([*+-])(\s+)/, ["wp-readme.whitespace", "wp-readme.listMarker", "wp-readme.whitespace"]],
5022
+ [/^(\s*)(\d+\.)(\s+)/, ["wp-readme.whitespace", "wp-readme.listMarker", "wp-readme.whitespace"]],
5023
+ [/^\s{4,}.*$/, "wp-readme.codeBlock"],
5024
+ [/^\t.*$/, "wp-readme.codeBlock"],
5025
+ [/^(\s*>)(.*)$/, ["wp-readme.quote", "wp-readme.quote"]],
5026
+ [/^([A-Za-z][A-Za-z0-9 /.-]*)(:)(.*)$/, ["wp-readme.field", "wp-readme.delimiter", "wp-readme.fieldValue"]],
5027
+ [/\[[^\]\n]+\]:\s*(?:https?:\/\/|mailto:|#)[^\s]+/, "wp-readme.link"],
5028
+ [/\[[^\]\n]+\]\([^)]+\)/, "wp-readme.link"],
5029
+ [/(?:https?:\/\/|mailto:)[^\s)]+/, "wp-readme.url"],
5030
+ [/\b(?:pressship|npx|wp|svn|npm|composer)\s+[^\n`]+/, "wp-readme.command"],
5031
+ [/`[^`\n]+`/, "wp-readme.inlineCode"],
5032
+ [/\*\*[^*\n][\s\S]*?\*\*/, "wp-readme.strong"],
5033
+ [/\*[^*\s\n][^*\n]*\*/, "wp-readme.emphasis"],
5034
+ [/\[(?:youtube|vimeo|wpvideo|playlist|audio|video)\b[^\]\n]*\]/i, "wp-readme.shortcode"],
5035
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, "wp-readme.url"]
5036
+ ]
5037
+ }
5038
+ });
5039
+ }
5040
+
5041
+ function definePressshipMonacoThemes(monaco) {
5042
+ monaco.editor.defineTheme("pressship-studio-dark", {
5043
+ base: "vs-dark",
5044
+ inherit: true,
5045
+ rules: [
5046
+ { token: "wp-readme.title", foreground: "f8fafc", fontStyle: "bold" },
5047
+ { token: "wp-readme.section", foreground: "7dd3fc", fontStyle: "bold" },
5048
+ { token: "wp-readme.subsection", foreground: "fbbf24", fontStyle: "bold" },
5049
+ { token: "wp-readme.field", foreground: "f78c6c", fontStyle: "bold" },
5050
+ { token: "wp-readme.fieldValue", foreground: "cbd5e1" },
5051
+ { token: "wp-readme.delimiter", foreground: "94a3b8" },
5052
+ { token: "wp-readme.listMarker", foreground: "a78bfa", fontStyle: "bold" },
5053
+ { token: "wp-readme.inlineCode", foreground: "c4b5fd" },
5054
+ { token: "wp-readme.codeBlock", foreground: "a7f3d0" },
5055
+ { token: "wp-readme.link", foreground: "93c5fd", fontStyle: "underline" },
5056
+ { token: "wp-readme.url", foreground: "67e8f9", fontStyle: "underline" },
5057
+ { token: "wp-readme.command", foreground: "fde68a" },
5058
+ { token: "wp-readme.shortcode", foreground: "f9a8d4" },
5059
+ { token: "wp-readme.quote", foreground: "9ca3af", fontStyle: "italic" },
5060
+ { token: "wp-readme.strong", foreground: "f8fafc", fontStyle: "bold" },
5061
+ { token: "wp-readme.emphasis", foreground: "e5e7eb", fontStyle: "italic" }
5062
+ ],
5063
+ colors: {
5064
+ "editor.background": "#1e1e1e"
5065
+ }
5066
+ });
5067
+
5068
+ monaco.editor.defineTheme("pressship-studio-light", {
5069
+ base: "vs",
5070
+ inherit: true,
5071
+ rules: [
5072
+ { token: "wp-readme.title", foreground: "1d4ed8", fontStyle: "bold" },
5073
+ { token: "wp-readme.section", foreground: "0f766e", fontStyle: "bold" },
5074
+ { token: "wp-readme.subsection", foreground: "b45309", fontStyle: "bold" },
5075
+ { token: "wp-readme.field", foreground: "be123c", fontStyle: "bold" },
5076
+ { token: "wp-readme.fieldValue", foreground: "334155" },
5077
+ { token: "wp-readme.delimiter", foreground: "64748b" },
5078
+ { token: "wp-readme.listMarker", foreground: "7c3aed", fontStyle: "bold" },
5079
+ { token: "wp-readme.inlineCode", foreground: "6d28d9" },
5080
+ { token: "wp-readme.codeBlock", foreground: "047857" },
5081
+ { token: "wp-readme.link", foreground: "0969da", fontStyle: "underline" },
5082
+ { token: "wp-readme.url", foreground: "0284c7", fontStyle: "underline" },
5083
+ { token: "wp-readme.command", foreground: "92400e" },
5084
+ { token: "wp-readme.shortcode", foreground: "be185d" },
5085
+ { token: "wp-readme.quote", foreground: "6b7280", fontStyle: "italic" },
5086
+ { token: "wp-readme.strong", foreground: "111827", fontStyle: "bold" },
5087
+ { token: "wp-readme.emphasis", foreground: "374151", fontStyle: "italic" }
5088
+ ],
5089
+ colors: {
5090
+ "editor.background": "#ffffff"
5091
+ }
5092
+ });
5093
+ }
5094
+
4084
5095
  function languageForPath(filePath) {
5096
+ const fileName = String(filePath ?? "").split("/").pop()?.toLowerCase();
5097
+ if (fileName === "readme.txt") {
5098
+ return "wordpress-readme";
5099
+ }
4085
5100
  const ext = filePath.split(".").pop()?.toLowerCase();
4086
5101
  const map = {
4087
5102
  css: "css",
@@ -4634,7 +5649,7 @@ function localCard(plugin, versionState) {
4634
5649
  Details
4635
5650
  </button>
4636
5651
  <button type="button" role="menuitem" data-action="manage-release" data-id="${escapeAttr(plugin.id)}">
4637
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5652
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
4638
5653
  Manage release
4639
5654
  </button>
4640
5655
  <button type="button" role="menuitem" data-action="version-state" data-id="${escapeAttr(plugin.id)}">
@@ -4670,7 +5685,7 @@ function localCard(plugin, versionState) {
4670
5685
  Open in Studio
4671
5686
  </button>
4672
5687
  <button type="button" class="ps-plugin-card-secondary" data-action="manage-release" data-id="${escapeAttr(plugin.id)}" ${missing ? "disabled" : ""}>
4673
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5688
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
4674
5689
  Manage release
4675
5690
  </button>
4676
5691
  </footer>
@@ -4683,6 +5698,12 @@ function localCard(plugin, versionState) {
4683
5698
  * =================================================================== */
4684
5699
 
4685
5700
  async function showDetails(scope, id) {
5701
+ if (state.activeView === "studio") {
5702
+ appendStudioCliCommand(studioCliCommand([
5703
+ "info",
5704
+ scope === "local" ? localPluginCliTarget(id) : quoteCliArg(id)
5705
+ ]));
5706
+ }
4686
5707
  els.detail.classList.add("is-open");
4687
5708
  els.detail.setAttribute("aria-hidden", "false");
4688
5709
  els.detail.innerHTML = `
@@ -4785,6 +5806,7 @@ function closeDetail() {
4785
5806
  * =================================================================== */
4786
5807
 
4787
5808
  async function createJob(body) {
5809
+ appendStudioCliCommand(studioCliCommandForJob(body));
4788
5810
  const job = await api("/api/jobs", { method: "POST", body });
4789
5811
  upsertJob(job);
4790
5812
  subscribeJob(job.id);
@@ -5587,7 +6609,7 @@ function commandItems() {
5587
6609
  id: "view:release",
5588
6610
  title: "Go to Release Management",
5589
6611
  subtitle: "Release status for every local plugin",
5590
- icon: "dashicons-update",
6612
+ icon: "ps-icon-rocket",
5591
6613
  run: () => showView("release")
5592
6614
  },
5593
6615
  {
@@ -5647,7 +6669,7 @@ function commandItems() {
5647
6669
  id: `release:${plugin.id}`,
5648
6670
  title: `Manage release • ${plugin.name}`,
5649
6671
  subtitle: plugin.slug,
5650
- icon: "dashicons-update",
6672
+ icon: "ps-icon-rocket",
5651
6673
  run: () => {
5652
6674
  void openStudio("local", plugin.id, { sidebarTab: "release" });
5653
6675
  }
@@ -6203,6 +7225,22 @@ function isSafeMarkdownHref(value) {
6203
7225
  }
6204
7226
  }
6205
7227
 
7228
+ function formatStudioBytes(bytes) {
7229
+ const value = Number(bytes);
7230
+ if (!Number.isFinite(value) || value < 0) {
7231
+ return "0 B";
7232
+ }
7233
+ const units = ["B", "KB", "MB", "GB"];
7234
+ let size = value;
7235
+ let unitIndex = 0;
7236
+ while (size >= 1024 && unitIndex < units.length - 1) {
7237
+ size /= 1024;
7238
+ unitIndex += 1;
7239
+ }
7240
+ const precision = unitIndex === 0 || size >= 10 ? 0 : 1;
7241
+ return `${size.toFixed(precision)} ${units[unitIndex]}`;
7242
+ }
7243
+
6206
7244
  function escapeHtml(value) {
6207
7245
  return String(value)
6208
7246
  .replaceAll("&", "&amp;")
@@ -6315,7 +7353,7 @@ function renderReleaseBoard() {
6315
7353
  els.release.innerHTML = emptyState({
6316
7354
  title: "No local plugins to release.",
6317
7355
  message: "Add a plugin folder to your Local Library, then come back here.",
6318
- icon: "dashicons-update"
7356
+ icon: "ps-icon-rocket"
6319
7357
  });
6320
7358
  return;
6321
7359
  }
@@ -6324,7 +7362,7 @@ function renderReleaseBoard() {
6324
7362
  els.release.innerHTML = `
6325
7363
  <div class="ps-card-toolbar" role="region" aria-label="Release board summary">
6326
7364
  <span class="ps-card-toolbar-count">
6327
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7365
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6328
7366
  ${escapeHtml(
6329
7367
  `${state.releaseBoard.plugins.length} plugin${state.releaseBoard.plugins.length === 1 ? "" : "s"} tracked`
6330
7368
  )}
@@ -6367,7 +7405,7 @@ function releaseBoardCard(entry) {
6367
7405
  : ""}
6368
7406
  <footer class="ps-release-board-card-footer">
6369
7407
  <button type="button" class="button button-primary" data-action="manage-release" data-id="${escapeAttr(entry.id)}" ${entry.exists === false ? "disabled" : ""}>
6370
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7408
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6371
7409
  Manage release
6372
7410
  </button>
6373
7411
  <button type="button" class="ps-plugin-card-secondary" data-action="version-state" data-id="${escapeAttr(entry.id)}" ${entry.exists === false ? "disabled" : ""}>
@@ -6398,6 +7436,9 @@ function setStudioSidebarTab(tab) {
6398
7436
  if (next === "release" && state.studio.scope === "local" && !state.studio.release.tags) {
6399
7437
  void loadStudioReleaseTags();
6400
7438
  }
7439
+ if (next === "release" && state.studio.scope === "local") {
7440
+ void refreshStudioIgnoreState({ files: true });
7441
+ }
6401
7442
  }
6402
7443
 
6403
7444
  function updateStudioSidebar() {
@@ -6422,7 +7463,7 @@ function renderStudioReleasePane() {
6422
7463
  return `
6423
7464
  <div class="studio-release-pane">
6424
7465
  <div class="studio-release-empty">
6425
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7466
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6426
7467
  <strong>Release management is local-only</strong>
6427
7468
  <p>Open a local plugin to manage its release lifecycle.</p>
6428
7469
  </div>
@@ -6433,7 +7474,7 @@ function renderStudioReleasePane() {
6433
7474
  return `
6434
7475
  <div class="studio-release-pane">
6435
7476
  <div class="studio-release-empty">
6436
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7477
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6437
7478
  <strong>Open a plugin to start</strong>
6438
7479
  </div>
6439
7480
  </div>
@@ -6457,6 +7498,7 @@ function renderStudioReleasePane() {
6457
7498
  ${renderStudioReleaseStepVersion(versionState, release)}
6458
7499
  ${renderStudioReleaseStepTags(versionState, release)}
6459
7500
  ${renderStudioReleaseStepValidate(versionState, release)}
7501
+ ${renderStudioReleaseStepIgnored()}
6460
7502
  ${renderStudioReleaseStepPublish(versionState, release)}
6461
7503
  </ol>
6462
7504
  </div>
@@ -6479,6 +7521,16 @@ function renderStudioReleaseStepShell(number, title, summary, body) {
6479
7521
  `;
6480
7522
  }
6481
7523
 
7524
+ function toggleStudioReleaseIgnored() {
7525
+ state.studio.release = {
7526
+ ...state.studio.release,
7527
+ ignoredCollapsed: !(state.studio.release?.ignoredCollapsed ?? true)
7528
+ };
7529
+ renderStudio();
7530
+ remountStudioEditorIfNeeded();
7531
+ updateStudioControls();
7532
+ }
7533
+
6482
7534
  function renderStudioReleaseStepVersion(versionState, release) {
6483
7535
  const localVersion = versionState?.localVersion ?? "—";
6484
7536
  const stable = versionState?.readmeStableTag ?? "—";
@@ -6694,6 +7746,72 @@ function renderStudioReleaseStepValidate(versionState, release) {
6694
7746
  return renderStudioReleaseStepShell(3, "Validate", summaryLine, body);
6695
7747
  }
6696
7748
 
7749
+ function renderStudioReleaseStepIgnored() {
7750
+ const ignoreState = state.studio.ignoreState ?? createInitialStudioIgnoreState();
7751
+ const patterns = ignoreState.patterns ?? [];
7752
+ const ignoredFiles = ignoreState.ignoredFiles ?? [];
7753
+ const collapsed = state.studio.release?.ignoredCollapsed ?? true;
7754
+ const summary = patterns.length
7755
+ ? `${patterns.length} pattern${patterns.length === 1 ? "" : "s"} · ${ignoredFiles.length} file${ignoredFiles.length === 1 ? "" : "s"}`
7756
+ : "No project ignore rules yet.";
7757
+
7758
+ let body;
7759
+ if (state.studio.ignoreLoading) {
7760
+ body = loadingShell("Reading ignored files…");
7761
+ } else if (state.studio.ignoreError) {
7762
+ body = `<p class="ps-release-step-error">${escapeHtml(state.studio.ignoreError)}</p>`;
7763
+ } else if (!patterns.length) {
7764
+ body = `<p class="ps-release-step-muted">No project ignore rules yet.</p>`;
7765
+ } else {
7766
+ body = `
7767
+ <ul class="ps-release-ignore-patterns">
7768
+ ${patterns.map((pattern) => renderStudioIgnorePatternRow(pattern)).join("")}
7769
+ </ul>
7770
+ ${ignoredFiles.length
7771
+ ? `<ul class="ps-release-ignored-files">
7772
+ ${ignoredFiles.slice(0, 8).map((file) => `
7773
+ <li>
7774
+ <span class="dashicons ${studioFileIcon(file.path)}" aria-hidden="true"></span>
7775
+ <span title="${escapeAttr(file.path)}">${escapeHtml(file.path)}</span>
7776
+ <small>${escapeHtml(file.ignoredBy ?? "")}</small>
7777
+ </li>
7778
+ `).join("")}
7779
+ </ul>
7780
+ ${ignoredFiles.length > 8 ? `<p class="ps-release-step-muted">${escapeHtml(`${ignoredFiles.length - 8} more ignored file${ignoredFiles.length - 8 === 1 ? "" : "s"}.`)}</p>` : ""}`
7781
+ : `<p class="ps-release-step-muted">The patterns do not match any visible project files.</p>`}
7782
+ `;
7783
+ }
7784
+
7785
+ return `
7786
+ <li class="ps-release-step ps-release-step-collapsible${collapsed ? " is-collapsed" : ""}">
7787
+ <span class="ps-release-step-connector" aria-hidden="true"></span>
7788
+ <header class="ps-release-step-header">
7789
+ <button class="ps-release-step-toggle" type="button" data-action="studio-release-toggle-ignored" aria-expanded="${collapsed ? "false" : "true"}">
7790
+ <span class="ps-release-step-marker">4</span>
7791
+ <span class="ps-release-step-heading">
7792
+ <strong>Ignored files</strong>
7793
+ <small>${escapeHtml(summary)}</small>
7794
+ </span>
7795
+ <span class="dashicons ${collapsed ? "dashicons-arrow-right-alt2" : "dashicons-arrow-down-alt2"} ps-release-step-toggle-icon" aria-hidden="true"></span>
7796
+ </button>
7797
+ </header>
7798
+ ${collapsed ? "" : `<div class="ps-release-step-body">${body}</div>`}
7799
+ </li>
7800
+ `;
7801
+ }
7802
+
7803
+ function renderStudioIgnorePatternRow(pattern) {
7804
+ const busy = state.studio.ignoreBusyPattern === pattern;
7805
+ return `
7806
+ <li>
7807
+ <code>${escapeHtml(pattern)}</code>
7808
+ <button class="button button-small ps-release-ignore-remove" type="button" data-action="studio-unignore-rule" data-pattern="${escapeAttr(pattern)}" title="${escapeAttr(`Remove ${pattern}`)}" aria-label="${escapeAttr(`Remove ${pattern}`)}" ${busy ? "disabled aria-busy=\"true\"" : ""}>
7809
+ <span class="dashicons ${busy ? "dashicons-update" : "dashicons-no-alt"}" aria-hidden="true"></span>
7810
+ </button>
7811
+ </li>
7812
+ `;
7813
+ }
7814
+
6697
7815
  function studioPublishRouteDetails(action) {
6698
7816
  switch (action) {
6699
7817
  case "submit":
@@ -6710,7 +7828,7 @@ function studioPublishRouteDetails(action) {
6710
7828
  return {
6711
7829
  label: "Release update",
6712
7830
  shortLabel: "release",
6713
- icon: "dashicons-update",
7831
+ icon: "ps-icon-rocket",
6714
7832
  description: "Use this for an approved plugin that already has an SVN repository. Pressship validates the package and publishes the current version after confirmation.",
6715
7833
  dryRunLabel: "Dry-run release",
6716
7834
  confirmLabel: "Confirm release",
@@ -6833,7 +7951,7 @@ function renderStudioReleaseStepPublish(versionState, release) {
6833
7951
  ? "Blocked — fix version state before publishing"
6834
7952
  : "";
6835
7953
 
6836
- return renderStudioReleaseStepShell(4, "Submit / Release", summary, body);
7954
+ return renderStudioReleaseStepShell(5, "Submit / Release", summary, body);
6837
7955
  }
6838
7956
 
6839
7957
  /* ===================================================================
@@ -6870,16 +7988,19 @@ async function refreshStudioAfterReleaseSwitch(result = {}) {
6870
7988
  const localId = state.studio.id;
6871
7989
  const selectedPath = state.studio.selectedFile?.path;
6872
7990
  try {
6873
- const [detail, filesResult, checkState, versionState] = await Promise.all([
7991
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
6874
7992
  api(`/api/plugins/local/${encodeURIComponent(localId)}`),
6875
7993
  api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
6876
7994
  api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
6877
- api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null)
7995
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null),
7996
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
6878
7997
  ]);
6879
7998
 
6880
7999
  applyStudioPluginDetail("local", localId, detail);
6881
8000
  state.studio.files = filesResult.files ?? [];
8001
+ state.studio.directories = filesResult.directories ?? [];
6882
8002
  applyStudioCheckState(checkState.state);
8003
+ applyStudioIgnoreState(ignoreState);
6883
8004
  if (versionState) {
6884
8005
  state.versionStates.set(localId, versionState);
6885
8006
  renderLocal();
@@ -6917,16 +8038,19 @@ async function refreshStudioAfterVersionChange(localId) {
6917
8038
 
6918
8039
  const selectedPath = state.studio.selectedFile?.path;
6919
8040
  try {
6920
- const [detail, filesResult, checkState, versionState] = await Promise.all([
8041
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
6921
8042
  api(`/api/plugins/local/${encodeURIComponent(localId)}`),
6922
8043
  api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
6923
8044
  api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
6924
- api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null)
8045
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null),
8046
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
6925
8047
  ]);
6926
8048
 
6927
8049
  applyStudioPluginDetail("local", localId, detail);
6928
8050
  state.studio.files = filesResult.files ?? [];
8051
+ state.studio.directories = filesResult.directories ?? [];
6929
8052
  applyStudioCheckState(checkState.state);
8053
+ applyStudioIgnoreState(ignoreState);
6930
8054
  if (versionState) {
6931
8055
  state.versionStates.set(localId, versionState);
6932
8056
  if (!state.studio.release.newTagDraft || state.studio.release.newTagDraft === versionState.latestSvnTag) {
@@ -7025,6 +8149,7 @@ async function switchStudioReleaseTag(tag, resolution) {
7025
8149
 
7026
8150
  async function bumpStudioReleaseVersion(localId, bump) {
7027
8151
  if (!localId || !["patch", "minor", "major"].includes(bump)) return;
8152
+ appendStudioCliCommand(studioCliCommand(["version", bump, localPluginCliTarget(localId)]));
7028
8153
  if (state.studio.id !== localId) {
7029
8154
  // Outside the funnel (e.g. invoked from a command palette). Run without
7030
8155
  // the inline busy state — the global notice is enough feedback.