pressship 0.1.14 → 0.2.0

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 }) => {
@@ -72,14 +76,14 @@ const HARNESS_ICON = {
72
76
  const STUDIO_LAYOUT_STORAGE_KEY = "pressship.studio.layout.v1";
73
77
 
74
78
  const STUDIO_LAYOUT_DEFAULTS = {
75
- files: 260,
79
+ files: 220,
76
80
  ai: 330,
77
81
  terminal: 190,
78
82
  checkNotes: 152
79
83
  };
80
84
 
81
85
  const STUDIO_LAYOUT_LIMITS = {
82
- files: { min: 180, max: 560 },
86
+ files: { min: 160, max: 440 },
83
87
  ai: { min: 260, max: 720 },
84
88
  terminal: { min: 100, max: 600 },
85
89
  checkNotes: { min: 80, max: 520 }
@@ -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;
2947
+ }
2948
+ if (state.studio.loadingFolders.has(normalized)) {
2949
+ return false;
2630
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);
2977
+ }
2978
+
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);
2633
3000
  }
2634
3001
 
2635
- function buildStudioFileTree(files) {
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>
@@ -3005,15 +3461,19 @@ function renderStudioAiChangeCard(change) {
3005
3461
  <article class="studio-ai-change-card${selected ? " is-selected" : ""}">
3006
3462
  <button type="button" class="studio-ai-change" data-action="studio-ai-change" data-path="${escapeAttr(change.path)}">
3007
3463
  <span class="dashicons ${change.status === "deleted" ? "dashicons-trash" : studioFileIcon(change.path)}" aria-hidden="true"></span>
3008
- <span>${escapeHtml(change.path)}</span>
3009
- <small>${escapeHtml(studioAiPatchSummary(change))}</small>
3464
+ <span class="studio-ai-change-main">
3465
+ <span class="studio-ai-change-path">${escapeHtml(change.path)}</span>
3466
+ <small>${escapeHtml(studioAiPatchSummary(change))}</small>
3467
+ </span>
3010
3468
  </button>
3011
3469
  <div class="studio-ai-change-actions" aria-label="${escapeAttr(`Review ${change.path}`)}">
3012
3470
  <button type="button" class="studio-ai-change-action is-accept" data-action="studio-ai-accept" data-path="${escapeAttr(change.path)}" title="Accept patch" aria-label="${escapeAttr(`Accept patch for ${change.path}`)}">
3013
3471
  <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
3472
+ <span>Accept</span>
3014
3473
  </button>
3015
3474
  <button type="button" class="studio-ai-change-action is-reject" data-action="studio-ai-reject" data-path="${escapeAttr(change.path)}" title="Reject patch" aria-label="${escapeAttr(`Reject patch for ${change.path}`)}">
3016
3475
  <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
3476
+ <span>Reject</span>
3017
3477
  </button>
3018
3478
  </div>
3019
3479
  </article>
@@ -3328,6 +3788,135 @@ function appendStudioTerminal(message, tone = "muted") {
3328
3788
  }
3329
3789
  }
3330
3790
 
3791
+ function appendStudioCliCommand(command) {
3792
+ if (state.activeView !== "studio" || !command || !Array.isArray(state.studio.terminal)) {
3793
+ return;
3794
+ }
3795
+ state.studio.terminalOpen = true;
3796
+ appendStudioTerminal(`$ ${command}`, "command");
3797
+ }
3798
+
3799
+ function quoteCliArg(value) {
3800
+ const text = String(value ?? "").trim();
3801
+ if (!text) {
3802
+ return "''";
3803
+ }
3804
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(text)
3805
+ ? text
3806
+ : `'${text.replace(/'/g, "'\\''")}'`;
3807
+ }
3808
+
3809
+ function studioCliCommand(parts) {
3810
+ return [STUDIO_CLI_PREFIX, ...parts].filter(Boolean).join(" ");
3811
+ }
3812
+
3813
+ function studioOpenCliCommand() {
3814
+ const parts = ["studio"];
3815
+ const host = window.location.hostname;
3816
+ const port = window.location.port;
3817
+ if (host && !["127.0.0.1", "localhost"].includes(host)) {
3818
+ parts.push("--host", quoteCliArg(host));
3819
+ }
3820
+ if (port && port !== "9477") {
3821
+ parts.push("--port", quoteCliArg(port));
3822
+ }
3823
+ return studioCliCommand(parts);
3824
+ }
3825
+
3826
+ function localPluginForCli(localId = state.studio.id) {
3827
+ return state.local.find((plugin) => plugin.id === localId) ??
3828
+ (state.studio.scope === "local" && state.studio.id === localId ? state.studio.plugin : null);
3829
+ }
3830
+
3831
+ function localPluginCliTarget(localId = state.studio.id) {
3832
+ const plugin = localPluginForCli(localId);
3833
+ return quoteCliArg(plugin?.path || plugin?.slug || localId || ".");
3834
+ }
3835
+
3836
+ function studioCliIgnoreFlags(localId = state.studio.id) {
3837
+ if (localId !== state.studio.id) {
3838
+ return [];
3839
+ }
3840
+ return studioIgnorePatterns().flatMap((pattern) => ["--ignore", quoteCliArg(pattern)]);
3841
+ }
3842
+
3843
+ function studioPublishCliCommand(localId, action, options = {}) {
3844
+ const normalizedAction = ["submit", "release"].includes(action) ? action : "auto";
3845
+ const parts = ["publish", localPluginCliTarget(localId)];
3846
+ if (normalizedAction !== "auto") {
3847
+ parts.push(`--${normalizedAction}`);
3848
+ }
3849
+ if (options.dryRun) {
3850
+ parts.push("--dry-run");
3851
+ }
3852
+ parts.push(...studioCliIgnoreFlags(localId), "--yes");
3853
+ return studioCliCommand(parts);
3854
+ }
3855
+
3856
+ function studioPlaygroundCliCommand(input) {
3857
+ const target = input.scope === "local" ? localPluginCliTarget(input.id) : quoteCliArg(input.id);
3858
+ const settings = state.settings ?? {};
3859
+ const parts = ["demo", target, "--reset", "--skip-browser"];
3860
+ if (input.wpVersion && input.wpVersion !== "latest") {
3861
+ parts.push("--wp", quoteCliArg(input.wpVersion));
3862
+ }
3863
+ if (settings.playgroundDatabaseMode && settings.playgroundDatabaseMode !== "auto") {
3864
+ parts.push("--database", quoteCliArg(settings.playgroundDatabaseMode));
3865
+ }
3866
+ if (settings.playgroundDatabaseMode === "mysql") {
3867
+ parts.push(
3868
+ "--mysql-host",
3869
+ quoteCliArg(settings.playgroundMysqlHost ?? "127.0.0.1"),
3870
+ "--mysql-port",
3871
+ quoteCliArg(settings.playgroundMysqlPort ?? 3306),
3872
+ "--mysql-user",
3873
+ quoteCliArg(settings.playgroundMysqlUser ?? "root"),
3874
+ "--mysql-database-prefix",
3875
+ quoteCliArg(settings.playgroundMysqlDatabasePrefix ?? "pressship_playground")
3876
+ );
3877
+ if (settings.playgroundMysqlPassword) {
3878
+ parts.push("--mysql-password", quoteCliArg(settings.playgroundMysqlPassword));
3879
+ }
3880
+ }
3881
+ return studioCliCommand(parts);
3882
+ }
3883
+
3884
+ function studioCliCommandForJob(input) {
3885
+ if (state.activeView !== "studio") {
3886
+ return "";
3887
+ }
3888
+ switch (input?.type) {
3889
+ case "clone": {
3890
+ const parts = ["get", quoteCliArg(input.slug)];
3891
+ if (input.destination) {
3892
+ parts.push(quoteCliArg(input.destination));
3893
+ }
3894
+ return studioCliCommand(parts);
3895
+ }
3896
+ case "play":
3897
+ return studioPlaygroundCliCommand(input);
3898
+ case "check":
3899
+ return studioCliCommand([
3900
+ "verify",
3901
+ localPluginCliTarget(input.localId),
3902
+ "--skip-readme-validator",
3903
+ ...studioCliIgnoreFlags(input.localId)
3904
+ ]);
3905
+ case "dry-run-publish":
3906
+ return studioPublishCliCommand(input.localId, input.action, { dryRun: true });
3907
+ case "confirm-publish": {
3908
+ const dryRun = state.studio.release?.dryRun;
3909
+ if (!dryRun?.approvalId || dryRun.approvalId !== input.approvalId) {
3910
+ return "";
3911
+ }
3912
+ const action = dryRun?.route?.action ?? "auto";
3913
+ return state.studio.id ? studioPublishCliCommand(state.studio.id, action) : "";
3914
+ }
3915
+ default:
3916
+ return "";
3917
+ }
3918
+ }
3919
+
3331
3920
  function selectedStudioAiAssistant() {
3332
3921
  return state.settings?.aiAssistant ?? "none";
3333
3922
  }
@@ -3393,6 +3982,287 @@ function applyStudioCheckState(checkState) {
3393
3982
  state.studio.checkRanAt = checkState.checkedAt ?? null;
3394
3983
  }
3395
3984
 
3985
+ function applyStudioIgnoreState(ignoreState) {
3986
+ state.studio.ignoreState = {
3987
+ ...createInitialStudioIgnoreState(),
3988
+ ...(ignoreState ?? {})
3989
+ };
3990
+ }
3991
+
3992
+ function applyStudioPackageSize(packageSize) {
3993
+ if (packageSize?.status === "calculating") {
3994
+ state.studio.packageSize = {
3995
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
3996
+ loading: true,
3997
+ error: "",
3998
+ stale: false
3999
+ };
4000
+ scheduleStudioPackageSizePoll();
4001
+ return;
4002
+ }
4003
+ if (packageSize?.status === "error") {
4004
+ state.studio.packageSize = {
4005
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4006
+ loading: false,
4007
+ error: packageSize.error ?? "Package size could not be calculated.",
4008
+ calculatedAt: packageSize.calculatedAt ?? new Date().toISOString(),
4009
+ stale: false
4010
+ };
4011
+ return;
4012
+ }
4013
+
4014
+ state.studio.packageSize = {
4015
+ ...createInitialStudioPackageSize(),
4016
+ ...(packageSize ?? {}),
4017
+ loading: false,
4018
+ error: "",
4019
+ calculatedAt: packageSize?.calculatedAt ?? new Date().toISOString(),
4020
+ stale: false
4021
+ };
4022
+ }
4023
+
4024
+ function markStudioPackageSizeStale() {
4025
+ const packageSize = state.studio.packageSize;
4026
+ if (!packageSize || !packageSize.calculatedAt) {
4027
+ return;
4028
+ }
4029
+ state.studio.packageSize = {
4030
+ ...packageSize,
4031
+ stale: true
4032
+ };
4033
+ }
4034
+
4035
+ async function refreshStudioPackageSize(options = {}) {
4036
+ if (state.studio.scope !== "local" || !state.studio.id) {
4037
+ return;
4038
+ }
4039
+ if (state.studio.packageSize?.loading && !options.poll) {
4040
+ return;
4041
+ }
4042
+ if (!options.force && state.studio.packageSize?.calculatedAt) {
4043
+ return;
4044
+ }
4045
+
4046
+ const localId = state.studio.id;
4047
+ if (options.notify) {
4048
+ appendStudioCliCommand(studioCliCommand([
4049
+ "pack",
4050
+ localPluginCliTarget(localId),
4051
+ "--no-verify",
4052
+ ...studioCliIgnoreFlags(localId),
4053
+ "--json"
4054
+ ]));
4055
+ }
4056
+ state.studio.packageSize = {
4057
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4058
+ loading: true,
4059
+ error: ""
4060
+ };
4061
+ if (options.render !== false) {
4062
+ renderStudio();
4063
+ remountStudioEditorIfNeeded();
4064
+ }
4065
+
4066
+ try {
4067
+ const packageSize = await api(`/api/plugins/local/${encodeURIComponent(localId)}/package-size`);
4068
+ if (state.studio.id !== localId) {
4069
+ return;
4070
+ }
4071
+ applyStudioPackageSize(packageSize);
4072
+ if (options.notify && packageSize.status !== "calculating") {
4073
+ if (packageSize.status === "error") {
4074
+ notice(packageSize.error ?? "Package size could not be calculated.", "error");
4075
+ } else {
4076
+ notice(
4077
+ packageSize.overLimit
4078
+ ? `Package is ${formatStudioBytes(packageSize.sizeBytes)}, over the WordPress.org 10 MB limit.`
4079
+ : `Package is ${formatStudioBytes(packageSize.sizeBytes)}.`,
4080
+ packageSize.overLimit ? "warning" : "success"
4081
+ );
4082
+ }
4083
+ }
4084
+ } catch (error) {
4085
+ if (state.studio.id !== localId) {
4086
+ return;
4087
+ }
4088
+ state.studio.packageSize = {
4089
+ ...(state.studio.packageSize ?? createInitialStudioPackageSize()),
4090
+ loading: false,
4091
+ error: error.message
4092
+ };
4093
+ if (options.notify) {
4094
+ notice(error.message, "error");
4095
+ }
4096
+ } finally {
4097
+ if (state.studio.id === localId && options.render !== false) {
4098
+ renderStudio();
4099
+ remountStudioEditorIfNeeded();
4100
+ updateStudioControls();
4101
+ }
4102
+ }
4103
+ }
4104
+
4105
+ function scheduleStudioPackageSizePoll() {
4106
+ if (studioPackageSizePollTimer) {
4107
+ return;
4108
+ }
4109
+ studioPackageSizePollTimer = window.setTimeout(() => {
4110
+ studioPackageSizePollTimer = null;
4111
+ void refreshStudioPackageSize({ force: true, render: true, poll: true });
4112
+ }, 1200);
4113
+ }
4114
+
4115
+ async function refreshStudioIgnoreState(options = {}) {
4116
+ if (state.studio.scope !== "local" || !state.studio.id) {
4117
+ return;
4118
+ }
4119
+
4120
+ const localId = state.studio.id;
4121
+ state.studio.ignoreLoading = true;
4122
+ state.studio.ignoreError = "";
4123
+ if (options.render !== false) {
4124
+ updateStudioSidebar();
4125
+ }
4126
+ try {
4127
+ const requests = [
4128
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`)
4129
+ ];
4130
+ if (options.files) {
4131
+ requests.push(api(`/api/plugins/local/${encodeURIComponent(localId)}/files`));
4132
+ }
4133
+ const [ignoreState, filesResult] = await Promise.all(requests);
4134
+ applyStudioIgnoreState(ignoreState);
4135
+ if (filesResult) {
4136
+ state.studio.files = filesResult.files ?? state.studio.files;
4137
+ state.studio.directories = filesResult.directories ?? state.studio.directories;
4138
+ }
4139
+ } catch (error) {
4140
+ state.studio.ignoreError = error.message;
4141
+ } finally {
4142
+ state.studio.ignoreLoading = false;
4143
+ state.studio.ignoreBusyPattern = "";
4144
+ state.studio.ignoreBusyPath = "";
4145
+ if (options.render !== false) {
4146
+ renderStudio();
4147
+ remountStudioEditorIfNeeded();
4148
+ updateStudioSidebar();
4149
+ updateStudioControls();
4150
+ }
4151
+ }
4152
+ }
4153
+
4154
+ function studioIgnorePatterns() {
4155
+ return state.studio.ignoreState?.patterns ?? [];
4156
+ }
4157
+
4158
+ function studioHasIgnorePattern(pattern) {
4159
+ return studioIgnorePatterns().includes(pattern);
4160
+ }
4161
+
4162
+ function studioFileIgnorePattern(filePath) {
4163
+ return String(filePath ?? "").replace(/^\.\/+/, "");
4164
+ }
4165
+
4166
+ function studioFolderIgnorePattern(folderPath) {
4167
+ const normalized = String(folderPath ?? "").replace(/^\.\/+/, "").replace(/\/+$/, "");
4168
+ return normalized ? `${normalized}/**` : "";
4169
+ }
4170
+
4171
+ function studioIgnoredFilesForPrefix(prefix) {
4172
+ const normalized = String(prefix ?? "").replace(/\/+$/, "");
4173
+ if (!normalized) {
4174
+ return [];
4175
+ }
4176
+
4177
+ const files = [];
4178
+ const seen = new Set();
4179
+ const addFile = (file) => {
4180
+ const filePath = String(file?.path ?? "");
4181
+ if (!filePath || seen.has(filePath)) {
4182
+ return;
4183
+ }
4184
+ if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
4185
+ seen.add(filePath);
4186
+ files.push(file);
4187
+ }
4188
+ };
4189
+
4190
+ for (const file of state.studio.ignoreState?.ignoredFiles ?? []) {
4191
+ addFile(file);
4192
+ }
4193
+ for (const file of state.studio.files ?? []) {
4194
+ if (file?.ignored) {
4195
+ addFile(file);
4196
+ }
4197
+ }
4198
+
4199
+ return files;
4200
+ }
4201
+
4202
+ async function addStudioIgnoreRule(pattern, options = {}) {
4203
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
4204
+ return;
4205
+ }
4206
+
4207
+ state.studio.ignoreBusyPattern = pattern;
4208
+ state.studio.ignoreBusyPath = options.path ?? "";
4209
+ updateStudioSidebar();
4210
+ try {
4211
+ const ignoreState = await api(`/api/plugins/local/${encodeURIComponent(state.studio.id)}/ignore-rules`, {
4212
+ method: "POST",
4213
+ body: { pattern }
4214
+ });
4215
+ applyStudioIgnoreState(ignoreState);
4216
+ markStudioPackageSizeStale();
4217
+ await refreshStudioIgnoreState({ files: true, render: false });
4218
+ if (state.studio.selectedFile?.path === ".pressshipignore") {
4219
+ await syncSelectedStudioFileFromDisk({ reason: "ignore-rule", reportErrors: true });
4220
+ }
4221
+ appendStudioTerminal(`Ignored ${options.label ?? pattern}.`, "success");
4222
+ } catch (error) {
4223
+ appendStudioTerminal(error.message, "error");
4224
+ notice(error.message, "error");
4225
+ } finally {
4226
+ state.studio.ignoreBusyPattern = "";
4227
+ state.studio.ignoreBusyPath = "";
4228
+ renderStudio();
4229
+ remountStudioEditorIfNeeded();
4230
+ updateStudioSidebar();
4231
+ updateStudioControls();
4232
+ }
4233
+ }
4234
+
4235
+ async function removeStudioIgnoreRule(pattern) {
4236
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
4237
+ return;
4238
+ }
4239
+
4240
+ state.studio.ignoreBusyPattern = pattern;
4241
+ updateStudioSidebar();
4242
+ try {
4243
+ const ignoreState = await api(`/api/plugins/local/${encodeURIComponent(state.studio.id)}/ignore-rules`, {
4244
+ method: "DELETE",
4245
+ body: { pattern }
4246
+ });
4247
+ applyStudioIgnoreState(ignoreState);
4248
+ markStudioPackageSizeStale();
4249
+ await refreshStudioIgnoreState({ files: true, render: false });
4250
+ if (state.studio.selectedFile?.path === ".pressshipignore") {
4251
+ await syncSelectedStudioFileFromDisk({ reason: "ignore-rule", reportErrors: true });
4252
+ }
4253
+ appendStudioTerminal(`Removed ignore rule ${pattern}.`, "success");
4254
+ } catch (error) {
4255
+ appendStudioTerminal(error.message, "error");
4256
+ notice(error.message, "error");
4257
+ } finally {
4258
+ state.studio.ignoreBusyPattern = "";
4259
+ renderStudio();
4260
+ remountStudioEditorIfNeeded();
4261
+ updateStudioSidebar();
4262
+ updateStudioControls();
4263
+ }
4264
+ }
4265
+
3396
4266
  function appendStudioAiMessage(role, text, tone = "muted") {
3397
4267
  state.studio.aiMessages.push({
3398
4268
  role,
@@ -3602,12 +4472,16 @@ function handleStudioJobEvent(id, payload) {
3602
4472
  }
3603
4473
  } else if (payload.type === "log") {
3604
4474
  if (isAiJob) {
3605
- appendStudioAiOutput(payload.data?.message ?? payload.data, "log");
3606
- if (payload.data?.data?.changedFiles) {
4475
+ const aiLogData = payload.data?.data;
4476
+ if (aiLogData?.changedFiles) {
3607
4477
  mergeStudioAiChangedFiles(payload.data.data.changedFiles);
3608
4478
  updateStudioAiSidebar();
3609
4479
  renderStudio();
3610
4480
  remountStudioEditorIfNeeded();
4481
+ } else if (aiLogData?.proposedChanges) {
4482
+ return;
4483
+ } else {
4484
+ appendStudioAiOutput(payload.data?.message ?? payload.data, "log");
3611
4485
  }
3612
4486
  } else {
3613
4487
  appendStudioTerminal(payload.data?.message ?? payload.data, "log");
@@ -3728,6 +4602,16 @@ function chooseInitialStudioFile(files, slug) {
3728
4602
  );
3729
4603
  }
3730
4604
 
4605
+ function canSaveStudioFile() {
4606
+ return Boolean(
4607
+ state.studio.scope === "local" &&
4608
+ state.studio.id &&
4609
+ !state.studio.readOnly &&
4610
+ state.studio.selectedFile &&
4611
+ state.studio.dirty
4612
+ );
4613
+ }
4614
+
3731
4615
  function updateStudioControls() {
3732
4616
  const canRun = Boolean(state.studio.id) && !state.studio.loading && !state.studio.running;
3733
4617
  const canCheck =
@@ -3749,12 +4633,17 @@ function updateStudioControls() {
3749
4633
  }
3750
4634
  if (studioCheck) {
3751
4635
  studioCheck.disabled = !canCheck;
4636
+ studioCheck.setAttribute("aria-label", state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check");
4637
+ studioCheck.title = state.studio.checkSummary ? "Re-run Plugin Check" : "Run Plugin Check";
3752
4638
  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"}`;
4639
+ ? `<span class="dashicons dashicons-update" aria-hidden="true"></span><span>Checking</span>`
4640
+ : `<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><span>${state.studio.checkSummary ? "Re-check" : "Check"}</span>`;
3755
4641
  }
3756
4642
  if (studioSave) {
3757
- studioSave.disabled = state.studio.readOnly || !state.studio.selectedFile || !state.studio.dirty;
4643
+ studioSave.disabled = !canSaveStudioFile();
4644
+ studioSave.title = `Save ${studioSaveShortcutLabel()}`;
4645
+ studioSave.setAttribute("aria-label", `Save ${studioSaveShortcutLabel()}`);
4646
+ studioSave.setAttribute("aria-keyshortcuts", studioSaveAriaShortcut());
3758
4647
  }
3759
4648
  updateStudioAiControls();
3760
4649
  }
@@ -3829,6 +4718,7 @@ async function mountStudioEditor(content) {
3829
4718
  modified: modifiedModel
3830
4719
  });
3831
4720
  state.studio.editorKind = "monaco-diff";
4721
+ revealStudioAiPatchChange(aiPatch);
3832
4722
  return;
3833
4723
  }
3834
4724
 
@@ -3846,6 +4736,11 @@ async function mountStudioEditor(content) {
3846
4736
  scrollBeyondLastLine: false
3847
4737
  });
3848
4738
  state.studio.editorKind = "monaco";
4739
+ state.studio.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
4740
+ if (canSaveStudioFile()) {
4741
+ void saveStudioFile();
4742
+ }
4743
+ });
3849
4744
  state.studio.editor.onDidChangeModelContent(() => {
3850
4745
  state.studio.draftContent = getStudioEditorValue();
3851
4746
  state.studio.dirty = state.studio.draftContent !== state.studio.fileContent;
@@ -3975,6 +4870,69 @@ function applyStudioAiPatchMarkers() {
3975
4870
  );
3976
4871
  }
3977
4872
 
4873
+ function firstStudioAiPatchLocation(change) {
4874
+ const hunks = change?.hunks?.length
4875
+ ? change.hunks
4876
+ : change
4877
+ ? buildStudioAiPatchHunks(change.beforeContent ?? "", change.afterContent ?? "")
4878
+ : [];
4879
+
4880
+ for (const hunk of hunks) {
4881
+ let oldLine = Number(hunk.oldStart) || 1;
4882
+ let newLine = Number(hunk.newStart) || 1;
4883
+
4884
+ for (const line of hunk.lines ?? []) {
4885
+ if (line.type === "add" || line.type === "delete") {
4886
+ return {
4887
+ oldLine: Math.max(1, oldLine),
4888
+ newLine: Math.max(1, newLine)
4889
+ };
4890
+ }
4891
+ if (line.type === "context") {
4892
+ oldLine += 1;
4893
+ newLine += 1;
4894
+ } else if (line.type === "add") {
4895
+ newLine += 1;
4896
+ } else if (line.type === "delete") {
4897
+ oldLine += 1;
4898
+ }
4899
+ }
4900
+ }
4901
+
4902
+ return null;
4903
+ }
4904
+
4905
+ function revealStudioAiPatchChange(change) {
4906
+ const location = firstStudioAiPatchLocation(change);
4907
+ if (!location) {
4908
+ return;
4909
+ }
4910
+
4911
+ requestAnimationFrame(() => {
4912
+ if (state.studio.editorKind === "monaco-diff" && state.studio.editor) {
4913
+ const originalEditor = state.studio.editor.getOriginalEditor?.();
4914
+ const modifiedEditor = state.studio.editor.getModifiedEditor?.();
4915
+ const originalModel = originalEditor?.getModel?.();
4916
+ const modifiedModel = modifiedEditor?.getModel?.();
4917
+ const oldLine = originalModel ? clampEditorLine(originalModel, location.oldLine) : location.oldLine;
4918
+ const newLine = modifiedModel ? clampEditorLine(modifiedModel, location.newLine) : location.newLine;
4919
+
4920
+ originalEditor?.revealLineInCenter?.(oldLine);
4921
+ modifiedEditor?.revealLineInCenter?.(newLine);
4922
+ originalEditor?.setPosition?.({ lineNumber: oldLine, column: 1 });
4923
+ modifiedEditor?.setPosition?.({ lineNumber: newLine, column: 1 });
4924
+ modifiedEditor?.focus?.();
4925
+ return;
4926
+ }
4927
+
4928
+ if (state.studio.editorKind === "textarea-diff" && state.studio.editor) {
4929
+ const offset = offsetForLineColumn(state.studio.editor.value, location.oldLine, 1);
4930
+ state.studio.editor.focus();
4931
+ state.studio.editor.setSelectionRange(offset, offset);
4932
+ }
4933
+ });
4934
+ }
4935
+
3978
4936
  function revealStudioCheckNote(line, column = 1) {
3979
4937
  if (!line) {
3980
4938
  return;
@@ -4057,6 +5015,7 @@ function getStudioEditorValue() {
4057
5015
 
4058
5016
  function ensureMonaco() {
4059
5017
  if (window.monaco) {
5018
+ configurePressshipMonaco(window.monaco);
4060
5019
  return Promise.resolve(window.monaco);
4061
5020
  }
4062
5021
  if (monacoPromise) {
@@ -4072,7 +5031,10 @@ function ensureMonaco() {
4072
5031
  vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs"
4073
5032
  }
4074
5033
  });
4075
- window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
5034
+ window.require(["vs/editor/editor.main"], () => {
5035
+ configurePressshipMonaco(window.monaco);
5036
+ resolve(window.monaco);
5037
+ }, reject);
4076
5038
  };
4077
5039
  script.onerror = () => reject(new Error("Could not load Monaco Editor."));
4078
5040
  document.head.appendChild(script);
@@ -4081,7 +5043,132 @@ function ensureMonaco() {
4081
5043
  return monacoPromise;
4082
5044
  }
4083
5045
 
5046
+ function configurePressshipMonaco(monaco) {
5047
+ if (!monaco || monacoConfigured) {
5048
+ return;
5049
+ }
5050
+ monacoConfigured = true;
5051
+ registerWordPressReadmeLanguage(monaco);
5052
+ definePressshipMonacoThemes(monaco);
5053
+ }
5054
+
5055
+ function registerWordPressReadmeLanguage(monaco) {
5056
+ const languageId = "wordpress-readme";
5057
+ if (!monaco.languages.getLanguages().some((language) => language.id === languageId)) {
5058
+ monaco.languages.register({
5059
+ id: languageId,
5060
+ aliases: ["WordPress Readme", "wordpress-readme"],
5061
+ extensions: [".txt"],
5062
+ filenames: ["readme.txt"]
5063
+ });
5064
+ }
5065
+
5066
+ monaco.languages.setLanguageConfiguration(languageId, {
5067
+ brackets: [
5068
+ ["[", "]"],
5069
+ ["(", ")"],
5070
+ ["`", "`"]
5071
+ ],
5072
+ autoClosingPairs: [
5073
+ { open: "`", close: "`" },
5074
+ { open: "[", close: "]" },
5075
+ { open: "(", close: ")" }
5076
+ ],
5077
+ surroundingPairs: [
5078
+ { open: "`", close: "`" },
5079
+ { open: "*", close: "*" },
5080
+ { open: "[", close: "]" },
5081
+ { open: "(", close: ")" }
5082
+ ],
5083
+ wordPattern: /(-?\d+(?:\.\d+)*)|([^\s`~!@#$%^&*()=+[{\]}\\|;:'",.<>/?]+)/g
5084
+ });
5085
+
5086
+ monaco.languages.setMonarchTokensProvider(languageId, {
5087
+ defaultToken: "wp-readme.text",
5088
+ tokenizer: {
5089
+ root: [
5090
+ [/^\s*={3}\s*.*?\s*={3}\s*$/, "wp-readme.title"],
5091
+ [/^\s*={2}\s*.*?\s*={2}\s*$/, "wp-readme.section"],
5092
+ [/^\s*=\s*.*?\s*=\s*$/, "wp-readme.subsection"],
5093
+ [/^(\s*)([*+-])(\s+)/, ["wp-readme.whitespace", "wp-readme.listMarker", "wp-readme.whitespace"]],
5094
+ [/^(\s*)(\d+\.)(\s+)/, ["wp-readme.whitespace", "wp-readme.listMarker", "wp-readme.whitespace"]],
5095
+ [/^\s{4,}.*$/, "wp-readme.codeBlock"],
5096
+ [/^\t.*$/, "wp-readme.codeBlock"],
5097
+ [/^(\s*>)(.*)$/, ["wp-readme.quote", "wp-readme.quote"]],
5098
+ [/^([A-Za-z][A-Za-z0-9 /.-]*)(:)(.*)$/, ["wp-readme.field", "wp-readme.delimiter", "wp-readme.fieldValue"]],
5099
+ [/\[[^\]\n]+\]:\s*(?:https?:\/\/|mailto:|#)[^\s]+/, "wp-readme.link"],
5100
+ [/\[[^\]\n]+\]\([^)]+\)/, "wp-readme.link"],
5101
+ [/(?:https?:\/\/|mailto:)[^\s)]+/, "wp-readme.url"],
5102
+ [/\b(?:pressship|npx|wp|svn|npm|composer)\s+[^\n`]+/, "wp-readme.command"],
5103
+ [/`[^`\n]+`/, "wp-readme.inlineCode"],
5104
+ [/\*\*[^*\n][\s\S]*?\*\*/, "wp-readme.strong"],
5105
+ [/\*[^*\s\n][^*\n]*\*/, "wp-readme.emphasis"],
5106
+ [/\[(?:youtube|vimeo|wpvideo|playlist|audio|video)\b[^\]\n]*\]/i, "wp-readme.shortcode"],
5107
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, "wp-readme.url"]
5108
+ ]
5109
+ }
5110
+ });
5111
+ }
5112
+
5113
+ function definePressshipMonacoThemes(monaco) {
5114
+ monaco.editor.defineTheme("pressship-studio-dark", {
5115
+ base: "vs-dark",
5116
+ inherit: true,
5117
+ rules: [
5118
+ { token: "wp-readme.title", foreground: "f8fafc", fontStyle: "bold" },
5119
+ { token: "wp-readme.section", foreground: "7dd3fc", fontStyle: "bold" },
5120
+ { token: "wp-readme.subsection", foreground: "fbbf24", fontStyle: "bold" },
5121
+ { token: "wp-readme.field", foreground: "f78c6c", fontStyle: "bold" },
5122
+ { token: "wp-readme.fieldValue", foreground: "cbd5e1" },
5123
+ { token: "wp-readme.delimiter", foreground: "94a3b8" },
5124
+ { token: "wp-readme.listMarker", foreground: "a78bfa", fontStyle: "bold" },
5125
+ { token: "wp-readme.inlineCode", foreground: "c4b5fd" },
5126
+ { token: "wp-readme.codeBlock", foreground: "a7f3d0" },
5127
+ { token: "wp-readme.link", foreground: "93c5fd", fontStyle: "underline" },
5128
+ { token: "wp-readme.url", foreground: "67e8f9", fontStyle: "underline" },
5129
+ { token: "wp-readme.command", foreground: "fde68a" },
5130
+ { token: "wp-readme.shortcode", foreground: "f9a8d4" },
5131
+ { token: "wp-readme.quote", foreground: "9ca3af", fontStyle: "italic" },
5132
+ { token: "wp-readme.strong", foreground: "f8fafc", fontStyle: "bold" },
5133
+ { token: "wp-readme.emphasis", foreground: "e5e7eb", fontStyle: "italic" }
5134
+ ],
5135
+ colors: {
5136
+ "editor.background": "#1e1e1e"
5137
+ }
5138
+ });
5139
+
5140
+ monaco.editor.defineTheme("pressship-studio-light", {
5141
+ base: "vs",
5142
+ inherit: true,
5143
+ rules: [
5144
+ { token: "wp-readme.title", foreground: "1d4ed8", fontStyle: "bold" },
5145
+ { token: "wp-readme.section", foreground: "0f766e", fontStyle: "bold" },
5146
+ { token: "wp-readme.subsection", foreground: "b45309", fontStyle: "bold" },
5147
+ { token: "wp-readme.field", foreground: "be123c", fontStyle: "bold" },
5148
+ { token: "wp-readme.fieldValue", foreground: "334155" },
5149
+ { token: "wp-readme.delimiter", foreground: "64748b" },
5150
+ { token: "wp-readme.listMarker", foreground: "7c3aed", fontStyle: "bold" },
5151
+ { token: "wp-readme.inlineCode", foreground: "6d28d9" },
5152
+ { token: "wp-readme.codeBlock", foreground: "047857" },
5153
+ { token: "wp-readme.link", foreground: "0969da", fontStyle: "underline" },
5154
+ { token: "wp-readme.url", foreground: "0284c7", fontStyle: "underline" },
5155
+ { token: "wp-readme.command", foreground: "92400e" },
5156
+ { token: "wp-readme.shortcode", foreground: "be185d" },
5157
+ { token: "wp-readme.quote", foreground: "6b7280", fontStyle: "italic" },
5158
+ { token: "wp-readme.strong", foreground: "111827", fontStyle: "bold" },
5159
+ { token: "wp-readme.emphasis", foreground: "374151", fontStyle: "italic" }
5160
+ ],
5161
+ colors: {
5162
+ "editor.background": "#ffffff"
5163
+ }
5164
+ });
5165
+ }
5166
+
4084
5167
  function languageForPath(filePath) {
5168
+ const fileName = String(filePath ?? "").split("/").pop()?.toLowerCase();
5169
+ if (fileName === "readme.txt") {
5170
+ return "wordpress-readme";
5171
+ }
4085
5172
  const ext = filePath.split(".").pop()?.toLowerCase();
4086
5173
  const map = {
4087
5174
  css: "css",
@@ -4634,7 +5721,7 @@ function localCard(plugin, versionState) {
4634
5721
  Details
4635
5722
  </button>
4636
5723
  <button type="button" role="menuitem" data-action="manage-release" data-id="${escapeAttr(plugin.id)}">
4637
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5724
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
4638
5725
  Manage release
4639
5726
  </button>
4640
5727
  <button type="button" role="menuitem" data-action="version-state" data-id="${escapeAttr(plugin.id)}">
@@ -4670,7 +5757,7 @@ function localCard(plugin, versionState) {
4670
5757
  Open in Studio
4671
5758
  </button>
4672
5759
  <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>
5760
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
4674
5761
  Manage release
4675
5762
  </button>
4676
5763
  </footer>
@@ -4683,6 +5770,12 @@ function localCard(plugin, versionState) {
4683
5770
  * =================================================================== */
4684
5771
 
4685
5772
  async function showDetails(scope, id) {
5773
+ if (state.activeView === "studio") {
5774
+ appendStudioCliCommand(studioCliCommand([
5775
+ "info",
5776
+ scope === "local" ? localPluginCliTarget(id) : quoteCliArg(id)
5777
+ ]));
5778
+ }
4686
5779
  els.detail.classList.add("is-open");
4687
5780
  els.detail.setAttribute("aria-hidden", "false");
4688
5781
  els.detail.innerHTML = `
@@ -4785,6 +5878,7 @@ function closeDetail() {
4785
5878
  * =================================================================== */
4786
5879
 
4787
5880
  async function createJob(body) {
5881
+ appendStudioCliCommand(studioCliCommandForJob(body));
4788
5882
  const job = await api("/api/jobs", { method: "POST", body });
4789
5883
  upsertJob(job);
4790
5884
  subscribeJob(job.id);
@@ -5587,7 +6681,7 @@ function commandItems() {
5587
6681
  id: "view:release",
5588
6682
  title: "Go to Release Management",
5589
6683
  subtitle: "Release status for every local plugin",
5590
- icon: "dashicons-update",
6684
+ icon: "ps-icon-rocket",
5591
6685
  run: () => showView("release")
5592
6686
  },
5593
6687
  {
@@ -5647,7 +6741,7 @@ function commandItems() {
5647
6741
  id: `release:${plugin.id}`,
5648
6742
  title: `Manage release • ${plugin.name}`,
5649
6743
  subtitle: plugin.slug,
5650
- icon: "dashicons-update",
6744
+ icon: "ps-icon-rocket",
5651
6745
  run: () => {
5652
6746
  void openStudio("local", plugin.id, { sidebarTab: "release" });
5653
6747
  }
@@ -6203,6 +7297,22 @@ function isSafeMarkdownHref(value) {
6203
7297
  }
6204
7298
  }
6205
7299
 
7300
+ function formatStudioBytes(bytes) {
7301
+ const value = Number(bytes);
7302
+ if (!Number.isFinite(value) || value < 0) {
7303
+ return "0 B";
7304
+ }
7305
+ const units = ["B", "KB", "MB", "GB"];
7306
+ let size = value;
7307
+ let unitIndex = 0;
7308
+ while (size >= 1024 && unitIndex < units.length - 1) {
7309
+ size /= 1024;
7310
+ unitIndex += 1;
7311
+ }
7312
+ const precision = unitIndex === 0 || size >= 10 ? 0 : 1;
7313
+ return `${size.toFixed(precision)} ${units[unitIndex]}`;
7314
+ }
7315
+
6206
7316
  function escapeHtml(value) {
6207
7317
  return String(value)
6208
7318
  .replaceAll("&", "&amp;")
@@ -6315,7 +7425,7 @@ function renderReleaseBoard() {
6315
7425
  els.release.innerHTML = emptyState({
6316
7426
  title: "No local plugins to release.",
6317
7427
  message: "Add a plugin folder to your Local Library, then come back here.",
6318
- icon: "dashicons-update"
7428
+ icon: "ps-icon-rocket"
6319
7429
  });
6320
7430
  return;
6321
7431
  }
@@ -6324,7 +7434,7 @@ function renderReleaseBoard() {
6324
7434
  els.release.innerHTML = `
6325
7435
  <div class="ps-card-toolbar" role="region" aria-label="Release board summary">
6326
7436
  <span class="ps-card-toolbar-count">
6327
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7437
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6328
7438
  ${escapeHtml(
6329
7439
  `${state.releaseBoard.plugins.length} plugin${state.releaseBoard.plugins.length === 1 ? "" : "s"} tracked`
6330
7440
  )}
@@ -6367,7 +7477,7 @@ function releaseBoardCard(entry) {
6367
7477
  : ""}
6368
7478
  <footer class="ps-release-board-card-footer">
6369
7479
  <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>
7480
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6371
7481
  Manage release
6372
7482
  </button>
6373
7483
  <button type="button" class="ps-plugin-card-secondary" data-action="version-state" data-id="${escapeAttr(entry.id)}" ${entry.exists === false ? "disabled" : ""}>
@@ -6398,6 +7508,9 @@ function setStudioSidebarTab(tab) {
6398
7508
  if (next === "release" && state.studio.scope === "local" && !state.studio.release.tags) {
6399
7509
  void loadStudioReleaseTags();
6400
7510
  }
7511
+ if (next === "release" && state.studio.scope === "local") {
7512
+ void refreshStudioIgnoreState({ files: true });
7513
+ }
6401
7514
  }
6402
7515
 
6403
7516
  function updateStudioSidebar() {
@@ -6422,7 +7535,7 @@ function renderStudioReleasePane() {
6422
7535
  return `
6423
7536
  <div class="studio-release-pane">
6424
7537
  <div class="studio-release-empty">
6425
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7538
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6426
7539
  <strong>Release management is local-only</strong>
6427
7540
  <p>Open a local plugin to manage its release lifecycle.</p>
6428
7541
  </div>
@@ -6433,7 +7546,7 @@ function renderStudioReleasePane() {
6433
7546
  return `
6434
7547
  <div class="studio-release-pane">
6435
7548
  <div class="studio-release-empty">
6436
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7549
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
6437
7550
  <strong>Open a plugin to start</strong>
6438
7551
  </div>
6439
7552
  </div>
@@ -6457,6 +7570,7 @@ function renderStudioReleasePane() {
6457
7570
  ${renderStudioReleaseStepVersion(versionState, release)}
6458
7571
  ${renderStudioReleaseStepTags(versionState, release)}
6459
7572
  ${renderStudioReleaseStepValidate(versionState, release)}
7573
+ ${renderStudioReleaseStepIgnored()}
6460
7574
  ${renderStudioReleaseStepPublish(versionState, release)}
6461
7575
  </ol>
6462
7576
  </div>
@@ -6479,6 +7593,16 @@ function renderStudioReleaseStepShell(number, title, summary, body) {
6479
7593
  `;
6480
7594
  }
6481
7595
 
7596
+ function toggleStudioReleaseIgnored() {
7597
+ state.studio.release = {
7598
+ ...state.studio.release,
7599
+ ignoredCollapsed: !(state.studio.release?.ignoredCollapsed ?? true)
7600
+ };
7601
+ renderStudio();
7602
+ remountStudioEditorIfNeeded();
7603
+ updateStudioControls();
7604
+ }
7605
+
6482
7606
  function renderStudioReleaseStepVersion(versionState, release) {
6483
7607
  const localVersion = versionState?.localVersion ?? "—";
6484
7608
  const stable = versionState?.readmeStableTag ?? "—";
@@ -6694,6 +7818,72 @@ function renderStudioReleaseStepValidate(versionState, release) {
6694
7818
  return renderStudioReleaseStepShell(3, "Validate", summaryLine, body);
6695
7819
  }
6696
7820
 
7821
+ function renderStudioReleaseStepIgnored() {
7822
+ const ignoreState = state.studio.ignoreState ?? createInitialStudioIgnoreState();
7823
+ const patterns = ignoreState.patterns ?? [];
7824
+ const ignoredFiles = ignoreState.ignoredFiles ?? [];
7825
+ const collapsed = state.studio.release?.ignoredCollapsed ?? true;
7826
+ const summary = patterns.length
7827
+ ? `${patterns.length} pattern${patterns.length === 1 ? "" : "s"} · ${ignoredFiles.length} file${ignoredFiles.length === 1 ? "" : "s"}`
7828
+ : "No project ignore rules yet.";
7829
+
7830
+ let body;
7831
+ if (state.studio.ignoreLoading) {
7832
+ body = loadingShell("Reading ignored files…");
7833
+ } else if (state.studio.ignoreError) {
7834
+ body = `<p class="ps-release-step-error">${escapeHtml(state.studio.ignoreError)}</p>`;
7835
+ } else if (!patterns.length) {
7836
+ body = `<p class="ps-release-step-muted">No project ignore rules yet.</p>`;
7837
+ } else {
7838
+ body = `
7839
+ <ul class="ps-release-ignore-patterns">
7840
+ ${patterns.map((pattern) => renderStudioIgnorePatternRow(pattern)).join("")}
7841
+ </ul>
7842
+ ${ignoredFiles.length
7843
+ ? `<ul class="ps-release-ignored-files">
7844
+ ${ignoredFiles.slice(0, 8).map((file) => `
7845
+ <li>
7846
+ <span class="dashicons ${studioFileIcon(file.path)}" aria-hidden="true"></span>
7847
+ <span title="${escapeAttr(file.path)}">${escapeHtml(file.path)}</span>
7848
+ <small>${escapeHtml(file.ignoredBy ?? "")}</small>
7849
+ </li>
7850
+ `).join("")}
7851
+ </ul>
7852
+ ${ignoredFiles.length > 8 ? `<p class="ps-release-step-muted">${escapeHtml(`${ignoredFiles.length - 8} more ignored file${ignoredFiles.length - 8 === 1 ? "" : "s"}.`)}</p>` : ""}`
7853
+ : `<p class="ps-release-step-muted">The patterns do not match any visible project files.</p>`}
7854
+ `;
7855
+ }
7856
+
7857
+ return `
7858
+ <li class="ps-release-step ps-release-step-collapsible${collapsed ? " is-collapsed" : ""}">
7859
+ <span class="ps-release-step-connector" aria-hidden="true"></span>
7860
+ <header class="ps-release-step-header">
7861
+ <button class="ps-release-step-toggle" type="button" data-action="studio-release-toggle-ignored" aria-expanded="${collapsed ? "false" : "true"}">
7862
+ <span class="ps-release-step-marker">4</span>
7863
+ <span class="ps-release-step-heading">
7864
+ <strong>Ignored files</strong>
7865
+ <small>${escapeHtml(summary)}</small>
7866
+ </span>
7867
+ <span class="dashicons ${collapsed ? "dashicons-arrow-right-alt2" : "dashicons-arrow-down-alt2"} ps-release-step-toggle-icon" aria-hidden="true"></span>
7868
+ </button>
7869
+ </header>
7870
+ ${collapsed ? "" : `<div class="ps-release-step-body">${body}</div>`}
7871
+ </li>
7872
+ `;
7873
+ }
7874
+
7875
+ function renderStudioIgnorePatternRow(pattern) {
7876
+ const busy = state.studio.ignoreBusyPattern === pattern;
7877
+ return `
7878
+ <li>
7879
+ <code>${escapeHtml(pattern)}</code>
7880
+ <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\"" : ""}>
7881
+ <span class="dashicons ${busy ? "dashicons-update" : "dashicons-no-alt"}" aria-hidden="true"></span>
7882
+ </button>
7883
+ </li>
7884
+ `;
7885
+ }
7886
+
6697
7887
  function studioPublishRouteDetails(action) {
6698
7888
  switch (action) {
6699
7889
  case "submit":
@@ -6710,7 +7900,7 @@ function studioPublishRouteDetails(action) {
6710
7900
  return {
6711
7901
  label: "Release update",
6712
7902
  shortLabel: "release",
6713
- icon: "dashicons-update",
7903
+ icon: "ps-icon-rocket",
6714
7904
  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
7905
  dryRunLabel: "Dry-run release",
6716
7906
  confirmLabel: "Confirm release",
@@ -6833,7 +8023,7 @@ function renderStudioReleaseStepPublish(versionState, release) {
6833
8023
  ? "Blocked — fix version state before publishing"
6834
8024
  : "";
6835
8025
 
6836
- return renderStudioReleaseStepShell(4, "Submit / Release", summary, body);
8026
+ return renderStudioReleaseStepShell(5, "Submit / Release", summary, body);
6837
8027
  }
6838
8028
 
6839
8029
  /* ===================================================================
@@ -6870,16 +8060,19 @@ async function refreshStudioAfterReleaseSwitch(result = {}) {
6870
8060
  const localId = state.studio.id;
6871
8061
  const selectedPath = state.studio.selectedFile?.path;
6872
8062
  try {
6873
- const [detail, filesResult, checkState, versionState] = await Promise.all([
8063
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
6874
8064
  api(`/api/plugins/local/${encodeURIComponent(localId)}`),
6875
8065
  api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
6876
8066
  api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
6877
- api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null)
8067
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null),
8068
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
6878
8069
  ]);
6879
8070
 
6880
8071
  applyStudioPluginDetail("local", localId, detail);
6881
8072
  state.studio.files = filesResult.files ?? [];
8073
+ state.studio.directories = filesResult.directories ?? [];
6882
8074
  applyStudioCheckState(checkState.state);
8075
+ applyStudioIgnoreState(ignoreState);
6883
8076
  if (versionState) {
6884
8077
  state.versionStates.set(localId, versionState);
6885
8078
  renderLocal();
@@ -6917,16 +8110,19 @@ async function refreshStudioAfterVersionChange(localId) {
6917
8110
 
6918
8111
  const selectedPath = state.studio.selectedFile?.path;
6919
8112
  try {
6920
- const [detail, filesResult, checkState, versionState] = await Promise.all([
8113
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
6921
8114
  api(`/api/plugins/local/${encodeURIComponent(localId)}`),
6922
8115
  api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
6923
8116
  api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
6924
- api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null)
8117
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null),
8118
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
6925
8119
  ]);
6926
8120
 
6927
8121
  applyStudioPluginDetail("local", localId, detail);
6928
8122
  state.studio.files = filesResult.files ?? [];
8123
+ state.studio.directories = filesResult.directories ?? [];
6929
8124
  applyStudioCheckState(checkState.state);
8125
+ applyStudioIgnoreState(ignoreState);
6930
8126
  if (versionState) {
6931
8127
  state.versionStates.set(localId, versionState);
6932
8128
  if (!state.studio.release.newTagDraft || state.studio.release.newTagDraft === versionState.latestSvnTag) {
@@ -7025,6 +8221,7 @@ async function switchStudioReleaseTag(tag, resolution) {
7025
8221
 
7026
8222
  async function bumpStudioReleaseVersion(localId, bump) {
7027
8223
  if (!localId || !["patch", "minor", "major"].includes(bump)) return;
8224
+ appendStudioCliCommand(studioCliCommand(["version", bump, localPluginCliTarget(localId)]));
7028
8225
  if (state.studio.id !== localId) {
7029
8226
  // Outside the funnel (e.g. invoked from a command palette). Run without
7030
8227
  // the inline busy state — the global notice is enough feedback.