pressship 0.1.13 → 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 }) => {
@@ -11,9 +15,11 @@ void import("/vendor/marked.esm.js")
11
15
  breaks: true
12
16
  });
13
17
  markdownParser = (markdown) => marked.parse(markdown, { async: false });
18
+ refreshStudioAiMarkdownIfReady();
14
19
  })
15
20
  .catch(() => {
16
21
  markdownParser = basicMarkdownToHtml;
22
+ refreshStudioAiMarkdownIfReady();
17
23
  });
18
24
 
19
25
  const MARKDOWN_ALLOWED_TAGS = new Set([
@@ -120,6 +126,8 @@ function clampStudioLayoutValue(key, value) {
120
126
  }
121
127
 
122
128
  const STUDIO_SIDEBAR_TAB_KEY = "pressship.studio.sidebar.tab.v1";
129
+ const STUDIO_PANEL_STORAGE_KEY = "pressship.studio.panels.v1";
130
+ const STUDIO_THEME_STORAGE_KEY = "pressship.studio.theme.v1";
123
131
 
124
132
  function loadStudioSidebarTab(pluginKey) {
125
133
  if (!pluginKey) {
@@ -147,6 +155,60 @@ function saveStudioSidebarTab(pluginKey, tab) {
147
155
  }
148
156
  }
149
157
 
158
+ function loadStudioPanelState() {
159
+ try {
160
+ const raw = typeof localStorage !== "undefined" ? localStorage.getItem(STUDIO_PANEL_STORAGE_KEY) : null;
161
+ const parsed = raw ? JSON.parse(raw) : {};
162
+ return normalizeStudioPanelState(parsed);
163
+ } catch {
164
+ return normalizeStudioPanelState();
165
+ }
166
+ }
167
+
168
+ function normalizeStudioPanelState(value = {}) {
169
+ return {
170
+ files: value.files !== false,
171
+ sidebar: value.sidebar !== false
172
+ };
173
+ }
174
+
175
+ function saveStudioPanelState(panels) {
176
+ try {
177
+ localStorage.setItem(STUDIO_PANEL_STORAGE_KEY, JSON.stringify(normalizeStudioPanelState(panels)));
178
+ } catch {
179
+ // ignore
180
+ }
181
+ }
182
+
183
+ function normalizeStudioTheme(value) {
184
+ return value === "light" ? "light" : "dark";
185
+ }
186
+
187
+ function loadStudioTheme() {
188
+ try {
189
+ const raw = typeof localStorage !== "undefined" ? localStorage.getItem(STUDIO_THEME_STORAGE_KEY) : null;
190
+ return normalizeStudioTheme(raw);
191
+ } catch {
192
+ return "dark";
193
+ }
194
+ }
195
+
196
+ function saveStudioTheme(theme) {
197
+ try {
198
+ localStorage.setItem(STUDIO_THEME_STORAGE_KEY, normalizeStudioTheme(theme));
199
+ } catch {
200
+ // ignore
201
+ }
202
+ }
203
+
204
+ function studioTheme() {
205
+ return normalizeStudioTheme(state.studio.theme);
206
+ }
207
+
208
+ function studioMonacoTheme() {
209
+ return studioTheme() === "light" ? "pressship-studio-light" : "pressship-studio-dark";
210
+ }
211
+
150
212
  function createInitialStudioRelease() {
151
213
  return {
152
214
  tags: null,
@@ -169,7 +231,31 @@ function createInitialStudioRelease() {
169
231
  switchingResolution: "",
170
232
  switchJobId: null,
171
233
  switchConflict: null,
172
- 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
173
259
  };
174
260
  }
175
261
 
@@ -342,6 +428,7 @@ const state = {
342
428
  id: null,
343
429
  plugin: null,
344
430
  files: [],
431
+ directories: [],
345
432
  selectedFile: null,
346
433
  fileContent: "",
347
434
  draftContent: "",
@@ -359,8 +446,11 @@ const state = {
359
446
  playgroundUrl: "",
360
447
  playgroundUrls: null,
361
448
  activeTab: "editor",
449
+ openFiles: [],
362
450
  terminalOpen: true,
363
451
  collapsedFolders: new Set(),
452
+ expandedIgnoredFolders: new Set(),
453
+ loadingFolders: new Set(),
364
454
  terminal: [],
365
455
  aiPrompt: "",
366
456
  aiJobId: null,
@@ -374,9 +464,17 @@ const state = {
374
464
  editorKind: null,
375
465
  editorModels: [],
376
466
  layout: loadStudioLayout(),
467
+ panels: loadStudioPanelState(),
468
+ theme: loadStudioTheme(),
377
469
  sidebarTab: "ai",
378
470
  playgroundVersionModal: null,
379
471
  release: createInitialStudioRelease(),
472
+ ignoreState: createInitialStudioIgnoreState(),
473
+ packageSize: createInitialStudioPackageSize(),
474
+ ignoreLoading: false,
475
+ ignoreError: "",
476
+ ignoreBusyPattern: "",
477
+ ignoreBusyPath: "",
380
478
  pendingConfirms: new Map()
381
479
  },
382
480
  activeView: "dashboard",
@@ -423,6 +521,30 @@ const els = {
423
521
  const isMac =
424
522
  typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform || "");
425
523
  let monacoPromise = null;
524
+ const VIEW_ROUTE_PATHS = {
525
+ dashboard: "/dashboard",
526
+ studio: "/studio",
527
+ remote: "/wordpress.org",
528
+ local: "/local",
529
+ release: "/release",
530
+ settings: "/settings"
531
+ };
532
+ const ROUTE_VIEW_ALIASES = {
533
+ "": "dashboard",
534
+ dashboard: "dashboard",
535
+ studio: "studio",
536
+ "wordpress.org": "remote",
537
+ remote: "remote",
538
+ local: "local",
539
+ release: "release",
540
+ settings: "settings"
541
+ };
542
+ let applyingLocationRoute = false;
543
+ let initialRouteLoaderVisible = false;
544
+ let studioFileSyncInFlight = false;
545
+ let studioLastExternalSyncAt = 0;
546
+ let studioLastDiskConflictKey = "";
547
+
426
548
  document.querySelectorAll("[data-kbd-mod]").forEach((node) => {
427
549
  node.textContent = isMac ? "⌘" : "Ctrl+";
428
550
  });
@@ -482,6 +604,14 @@ document.addEventListener("keydown", (event) => {
482
604
  return;
483
605
  }
484
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
+
485
615
  if (event.key === "Escape" && state.command.open) {
486
616
  event.preventDefault();
487
617
  closeCommandPalette();
@@ -525,6 +655,258 @@ els.commandInput?.addEventListener("input", () => {
525
655
  renderCommandPalette();
526
656
  });
527
657
 
658
+ window.addEventListener("popstate", () => {
659
+ void applyLocationRoute({ replaceRoute: true });
660
+ });
661
+
662
+ window.addEventListener("focus", () => {
663
+ syncSelectedStudioFileOnResume();
664
+ });
665
+
666
+ document.addEventListener("visibilitychange", () => {
667
+ if (!document.hidden) {
668
+ syncSelectedStudioFileOnResume();
669
+ }
670
+ });
671
+
672
+ function normalizeViewId(view) {
673
+ return Object.prototype.hasOwnProperty.call(VIEW_ROUTE_PATHS, view) ? view : "dashboard";
674
+ }
675
+
676
+ function normalizeBrowserPath(pathname = window.location.pathname) {
677
+ const trimmed = pathname.replace(/\/+$/, "");
678
+ return trimmed || "/";
679
+ }
680
+
681
+ function shouldShowInitialRouteLoader() {
682
+ const path = normalizeBrowserPath();
683
+ return path !== "/" && path !== VIEW_ROUTE_PATHS.dashboard;
684
+ }
685
+
686
+ function showInitialRouteLoader() {
687
+ if (!shouldShowInitialRouteLoader() || initialRouteLoaderVisible) {
688
+ return;
689
+ }
690
+ initialRouteLoaderVisible = true;
691
+ document.body.classList.add("is-initial-route-loading");
692
+ const loader = document.createElement("div");
693
+ loader.id = "initial-route-loader";
694
+ loader.className = "ps-initial-route-loader";
695
+ loader.setAttribute("role", "status");
696
+ loader.setAttribute("aria-live", "polite");
697
+ loader.innerHTML = `
698
+ <div class="ps-initial-route-loader-card">
699
+ <img src="/brand/pressship-symbol.png" alt="" />
700
+ <span class="dashicons dashicons-update" aria-hidden="true"></span>
701
+ <strong>Loading Pressship Studio</strong>
702
+ <p>Restoring the requested page...</p>
703
+ </div>
704
+ `;
705
+ document.body.append(loader);
706
+ }
707
+
708
+ function hideInitialRouteLoader() {
709
+ if (!initialRouteLoaderVisible) {
710
+ return;
711
+ }
712
+ initialRouteLoaderVisible = false;
713
+ document.body.classList.remove("is-initial-route-loading");
714
+ document.getElementById("initial-route-loader")?.remove();
715
+ }
716
+
717
+ function decodeRouteSegment(segment) {
718
+ try {
719
+ return decodeURIComponent(segment);
720
+ } catch {
721
+ return segment;
722
+ }
723
+ }
724
+
725
+ function encodeRouteSegments(value) {
726
+ return String(value ?? "")
727
+ .split("/")
728
+ .filter(Boolean)
729
+ .map((segment) => encodeURIComponent(segment))
730
+ .join("/");
731
+ }
732
+
733
+ function parseLocationRoute(pathname = window.location.pathname) {
734
+ const segments = pathname
735
+ .split("/")
736
+ .filter(Boolean)
737
+ .map((segment) => decodeRouteSegment(segment));
738
+ const head = segments[0] ?? "";
739
+
740
+ if (head === "studio") {
741
+ return {
742
+ view: "studio",
743
+ studio: {
744
+ project: segments[1] ?? "",
745
+ filePath: segments.slice(2).join("/")
746
+ }
747
+ };
748
+ }
749
+
750
+ return {
751
+ view: ROUTE_VIEW_ALIASES[head] ?? "dashboard"
752
+ };
753
+ }
754
+
755
+ function applyActiveViewShell(view) {
756
+ const nextView = normalizeViewId(view);
757
+ state.activeView = nextView;
758
+ document.body.dataset.activeView = nextView;
759
+ document.querySelectorAll(".view").forEach((node) => node.classList.remove("is-active"));
760
+ document.getElementById(`view-${nextView}`)?.classList.add("is-active");
761
+ document
762
+ .querySelectorAll("#adminmenu li")
763
+ .forEach((node) => node.classList.remove("wp-has-current-submenu"));
764
+ document
765
+ .querySelector(`#adminmenu li[data-view="${nextView}"]`)
766
+ ?.classList.add("wp-has-current-submenu");
767
+ return nextView;
768
+ }
769
+
770
+ function primeInitialRouteState(route) {
771
+ const nextView = applyActiveViewShell(route.view);
772
+ if (nextView !== "studio" || !route.studio?.project || state.studio.id) {
773
+ return;
774
+ }
775
+
776
+ const filePath = route.studio.filePath;
777
+ state.studio = {
778
+ ...state.studio,
779
+ scope: null,
780
+ id: route.studio.project,
781
+ plugin: { slug: route.studio.project, name: route.studio.project },
782
+ files: [],
783
+ directories: [],
784
+ selectedFile: filePath
785
+ ? {
786
+ path: filePath,
787
+ name: filePath.split("/").pop() ?? filePath,
788
+ directory: filePath.includes("/") ? filePath.split("/").slice(0, -1).join("/") : "",
789
+ size: 0
790
+ }
791
+ : null,
792
+ fileContent: "",
793
+ draftContent: "",
794
+ readOnly: true,
795
+ dirty: false,
796
+ loading: true,
797
+ running: false,
798
+ checking: false,
799
+ jobId: null,
800
+ checkJobId: null,
801
+ activeTab: "editor",
802
+ openFiles: filePath ? [filePath] : [],
803
+ terminal: [
804
+ `Opening ${route.studio.project} from URL...`,
805
+ { message: `$ ${studioOpenCliCommand()}`, tone: "command" }
806
+ ],
807
+ collapsedFolders: new Set(),
808
+ expandedIgnoredFolders: new Set(),
809
+ loadingFolders: new Set(),
810
+ pendingConfirms: new Map()
811
+ };
812
+ }
813
+
814
+ function studioProjectRouteSegment() {
815
+ const plugin = state.studio.plugin;
816
+ const localPlugin = state.local.find((item) => item.id === state.studio.id);
817
+ return plugin?.slug || localPlugin?.slug || plugin?.name || state.studio.id || "";
818
+ }
819
+
820
+ function studioRoutePathForState() {
821
+ if (!state.studio.id) {
822
+ return VIEW_ROUTE_PATHS.studio;
823
+ }
824
+ const project = studioProjectRouteSegment();
825
+ if (!project) {
826
+ return VIEW_ROUTE_PATHS.studio;
827
+ }
828
+ const filePath =
829
+ state.studio.activeTab === "editor" && state.studio.selectedFile?.path
830
+ ? encodeRouteSegments(state.studio.selectedFile.path)
831
+ : "";
832
+ const projectPath = encodeURIComponent(project);
833
+ return filePath ? `/studio/${projectPath}/${filePath}` : `/studio/${projectPath}`;
834
+ }
835
+
836
+ function routePathForState() {
837
+ const view = normalizeViewId(state.activeView);
838
+ return view === "studio" ? studioRoutePathForState() : VIEW_ROUTE_PATHS[view];
839
+ }
840
+
841
+ function updateRouteFromState(options = {}) {
842
+ if (applyingLocationRoute && !options.force) {
843
+ return;
844
+ }
845
+ const nextPath = routePathForState();
846
+ if (normalizeBrowserPath(nextPath) === normalizeBrowserPath()) {
847
+ return;
848
+ }
849
+ const method = options.replace ? "replaceState" : "pushState";
850
+ history[method]({ pressshipView: state.activeView }, "", nextPath);
851
+ }
852
+
853
+ function resolveStudioRouteProject(project) {
854
+ if (!project) {
855
+ return null;
856
+ }
857
+ const normalizedProject = project.toLowerCase();
858
+ const matchesProject = (candidate) => {
859
+ if (!candidate) {
860
+ return false;
861
+ }
862
+ return candidate === project || candidate.toLowerCase() === normalizedProject;
863
+ };
864
+ const localPlugin = state.local.find((plugin) =>
865
+ [plugin.slug, plugin.id, plugin.name].some((candidate) => matchesProject(candidate))
866
+ );
867
+ if (localPlugin) {
868
+ return { scope: "local", id: localPlugin.id };
869
+ }
870
+
871
+ const remotePlugin = state.remote.find((plugin) =>
872
+ [plugin.slug, plugin.name].some((candidate) => matchesProject(candidate))
873
+ );
874
+ return { scope: "remote", id: remotePlugin?.slug || project };
875
+ }
876
+
877
+ async function applyLocationRoute(options = {}) {
878
+ const route = parseLocationRoute();
879
+ applyingLocationRoute = true;
880
+ try {
881
+ if (route.view === "studio") {
882
+ if (route.studio?.project) {
883
+ const target = resolveStudioRouteProject(route.studio.project);
884
+ if (target) {
885
+ await openStudio(target.scope, target.id, {
886
+ filePath: route.studio.filePath,
887
+ updateRoute: false
888
+ });
889
+ } else {
890
+ await showView("studio", { updateRoute: false });
891
+ renderStudio();
892
+ }
893
+ } else {
894
+ await showView("studio", { updateRoute: false });
895
+ renderStudio();
896
+ }
897
+ } else {
898
+ await showView(route.view, { updateRoute: false });
899
+ }
900
+ } finally {
901
+ applyingLocationRoute = false;
902
+ }
903
+
904
+ if (options.replaceRoute !== false) {
905
+ updateRouteFromState({ replace: true, force: true });
906
+ }
907
+ }
908
+
909
+ showInitialRouteLoader();
528
910
  void boot();
529
911
 
530
912
  async function boot() {
@@ -532,13 +914,15 @@ async function boot() {
532
914
  state.bootstrap = await api("/api/bootstrap");
533
915
  refreshTokenFromBootstrap(state.bootstrap);
534
916
  } catch (error) {
917
+ hideInitialRouteLoader();
535
918
  notice(`Could not load bootstrap state: ${error.message}`, "error");
536
919
  return;
537
920
  }
538
921
  state.settings = state.bootstrap.settings ?? null;
539
922
  state.playgrounds = state.bootstrap.playgrounds ?? [];
540
923
  state.aiAssistance.harnesses = state.bootstrap.aiHarnesses ?? [];
541
- document.body.dataset.activeView = state.activeView;
924
+ const initialRoute = parseLocationRoute();
925
+ primeInitialRouteState(initialRoute);
542
926
  renderAccount();
543
927
  for (const job of state.bootstrap.jobs ?? []) {
544
928
  upsertJob(job);
@@ -549,8 +933,16 @@ async function boot() {
549
933
  renderDashboard();
550
934
  renderStudio();
551
935
  renderPlaygroundsMenu();
936
+ if (state.activeView === "release") {
937
+ void loadReleaseBoard();
938
+ }
552
939
  void loadAiAssistance();
553
- await Promise.all([loadRemote(), loadLocal()]);
940
+ try {
941
+ await Promise.all([loadRemote(), loadLocal()]);
942
+ await applyLocationRoute({ replaceRoute: true });
943
+ } finally {
944
+ hideInitialRouteLoader();
945
+ }
554
946
  }
555
947
 
556
948
  function renderAccount() {
@@ -598,14 +990,48 @@ async function runAction(name, element) {
598
990
  await selectStudioFile(element.dataset.path);
599
991
  return;
600
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
+
1011
+ case "studio-close-file-tab":
1012
+ await closeStudioFileTab(element.dataset.path);
1013
+ return;
1014
+
601
1015
  case "studio-toggle-folder":
602
- toggleStudioFolder(element.dataset.folder);
1016
+ await toggleStudioFolder(element.dataset.folder);
603
1017
  return;
604
1018
 
605
1019
  case "studio-toggle-terminal":
606
1020
  toggleStudioTerminal();
607
1021
  return;
608
1022
 
1023
+ case "studio-toggle-files":
1024
+ toggleStudioPanel("files");
1025
+ return;
1026
+
1027
+ case "studio-toggle-sidebar":
1028
+ toggleStudioPanel("sidebar");
1029
+ return;
1030
+
1031
+ case "studio-open-sidebar-tab":
1032
+ openStudioSidebarTab(element.dataset.tab);
1033
+ return;
1034
+
609
1035
  case "studio-save":
610
1036
  await saveStudioFile();
611
1037
  return;
@@ -614,6 +1040,14 @@ async function runAction(name, element) {
614
1040
  await runStudioCheck();
615
1041
  return;
616
1042
 
1043
+ case "studio-package-size":
1044
+ await refreshStudioPackageSize({ force: true, notify: true });
1045
+ return;
1046
+
1047
+ case "studio-toggle-theme":
1048
+ toggleStudioTheme();
1049
+ return;
1050
+
617
1051
  case "studio-check-note":
618
1052
  revealStudioCheckNote(Number(element.dataset.line || 1), Number(element.dataset.column || 1));
619
1053
  return;
@@ -727,7 +1161,14 @@ async function runAction(name, element) {
727
1161
  return;
728
1162
 
729
1163
  case "studio-release-refresh":
730
- 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();
731
1172
  return;
732
1173
 
733
1174
  case "studio-release-switch":
@@ -1089,6 +1530,7 @@ async function openStudio(scope, id, options = {}) {
1089
1530
  id,
1090
1531
  plugin: null,
1091
1532
  files: [],
1533
+ directories: [],
1092
1534
  selectedFile: null,
1093
1535
  fileContent: "",
1094
1536
  draftContent: "",
@@ -1106,9 +1548,15 @@ async function openStudio(scope, id, options = {}) {
1106
1548
  playgroundUrl: "",
1107
1549
  playgroundUrls: null,
1108
1550
  activeTab: "editor",
1551
+ openFiles: [],
1109
1552
  terminalOpen: true,
1110
1553
  collapsedFolders: new Set(),
1111
- 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
+ ],
1112
1560
  aiPrompt: "",
1113
1561
  aiJobId: null,
1114
1562
  aiRunning: false,
@@ -1121,13 +1569,21 @@ async function openStudio(scope, id, options = {}) {
1121
1569
  editorKind: null,
1122
1570
  editorModels: [],
1123
1571
  layout: state.studio.layout ?? loadStudioLayout(),
1572
+ panels: normalizeStudioPanelState(state.studio.panels ?? loadStudioPanelState()),
1573
+ theme: normalizeStudioTheme(state.studio.theme ?? loadStudioTheme()),
1124
1574
  sidebarTab,
1125
1575
  playgroundVersionModal: null,
1126
1576
  release: createInitialStudioRelease(),
1577
+ ignoreState: createInitialStudioIgnoreState(),
1578
+ packageSize: createInitialStudioPackageSize(),
1579
+ ignoreLoading: false,
1580
+ ignoreError: "",
1581
+ ignoreBusyPattern: "",
1582
+ ignoreBusyPath: "",
1127
1583
  pendingConfirms: new Map()
1128
1584
  };
1129
1585
  saveStudioSidebarTab(pluginKey, sidebarTab);
1130
- showView("studio");
1586
+ await showView("studio", { updateRoute: false });
1131
1587
  renderStudio();
1132
1588
  if (scope === "local" && sidebarTab === "release") {
1133
1589
  void loadStudioReleaseTags();
@@ -1139,33 +1595,56 @@ async function openStudio(scope, id, options = {}) {
1139
1595
  state.studio.loading = false;
1140
1596
 
1141
1597
  if (scope === "local") {
1142
- const [result, checkState] = await Promise.all([
1598
+ const [result, checkState, ignoreState] = await Promise.all([
1143
1599
  api(`/api/plugins/local/${encodeURIComponent(id)}/files`),
1144
- 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())
1145
1602
  ]);
1146
1603
  state.studio.files = result.files ?? [];
1604
+ state.studio.directories = result.directories ?? [];
1147
1605
  applyStudioCheckState(checkState.state);
1606
+ applyStudioIgnoreState(ignoreState);
1148
1607
  renderStudio();
1149
- const initialFile = chooseInitialStudioFile(state.studio.files, state.studio.plugin?.slug);
1608
+ const requestedFile = options.filePath
1609
+ ? state.studio.files.find((file) => file.path === options.filePath)
1610
+ : null;
1611
+ if (options.filePath && !requestedFile) {
1612
+ appendStudioTerminal(`Route file not found: ${options.filePath}`, "warning");
1613
+ }
1614
+ const initialFile = requestedFile ?? chooseInitialStudioFile(state.studio.files, state.studio.plugin?.slug);
1150
1615
  if (initialFile) {
1151
- await selectStudioFile(initialFile.path);
1616
+ await selectStudioFile(initialFile.path, {
1617
+ updateRoute: options.updateRoute,
1618
+ replaceRoute: options.replaceRoute
1619
+ });
1152
1620
  } else {
1153
1621
  state.studio.draftContent = "";
1154
1622
  remountStudioEditorIfNeeded();
1623
+ if (options.updateRoute !== false) {
1624
+ updateRouteFromState({ replace: options.replaceRoute });
1625
+ }
1155
1626
  }
1627
+ void refreshStudioPackageSize({ render: true });
1156
1628
  } else {
1157
1629
  state.studio.files = [{ path: "readme.txt", name: "readme.txt", directory: "", size: detail.readme?.length ?? 0 }];
1158
1630
  state.studio.selectedFile = state.studio.files[0];
1631
+ state.studio.openFiles = ["readme.txt"];
1159
1632
  state.studio.fileContent = detail.readme ?? "No hosted readme.txt could be loaded.";
1160
1633
  state.studio.draftContent = state.studio.fileContent;
1161
1634
  state.studio.readOnly = true;
1162
1635
  renderStudio();
1163
1636
  remountStudioEditorIfNeeded();
1637
+ if (options.updateRoute !== false) {
1638
+ updateRouteFromState({ replace: options.replaceRoute });
1639
+ }
1164
1640
  }
1165
1641
  } catch (error) {
1166
1642
  state.studio.loading = false;
1167
1643
  appendStudioTerminal(error.message, "error");
1168
1644
  renderStudio();
1645
+ if (options.updateRoute !== false) {
1646
+ updateRouteFromState({ replace: options.replaceRoute });
1647
+ }
1169
1648
  }
1170
1649
  }
1171
1650
 
@@ -1188,6 +1667,7 @@ async function selectStudioFile(relativePath, options = {}) {
1188
1667
  return;
1189
1668
  }
1190
1669
 
1670
+ ensureStudioFileTab(relativePath);
1191
1671
  state.studio.selectedFile = state.studio.files.find((file) => file.path === relativePath) ?? {
1192
1672
  path: relativePath,
1193
1673
  name: relativePath.split("/").pop() ?? relativePath,
@@ -1200,6 +1680,9 @@ async function selectStudioFile(relativePath, options = {}) {
1200
1680
  state.studio.activeTab = "editor";
1201
1681
  renderStudio();
1202
1682
  remountStudioEditorIfNeeded();
1683
+ if (options.updateRoute !== false) {
1684
+ updateRouteFromState({ replace: options.replaceRoute });
1685
+ }
1203
1686
 
1204
1687
  try {
1205
1688
  const result = await api(
@@ -1213,8 +1696,100 @@ async function selectStudioFile(relativePath, options = {}) {
1213
1696
  remountStudioEditorIfNeeded();
1214
1697
  } catch (error) {
1215
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;
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;
1216
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;
1781
+ }
1782
+ }
1783
+
1784
+ function ensureStudioFileTab(relativePath) {
1785
+ if (!relativePath) {
1786
+ return;
1217
1787
  }
1788
+ const openFiles = Array.isArray(state.studio.openFiles) ? state.studio.openFiles : [];
1789
+ if (!openFiles.includes(relativePath)) {
1790
+ openFiles.push(relativePath);
1791
+ }
1792
+ state.studio.openFiles = openFiles;
1218
1793
  }
1219
1794
 
1220
1795
  async function saveStudioFile() {
@@ -1238,6 +1813,10 @@ async function saveStudioFile() {
1238
1813
  state.studio.draftContent = content;
1239
1814
  state.studio.dirty = false;
1240
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
+ }
1241
1820
  renderStudio();
1242
1821
  remountStudioEditorIfNeeded();
1243
1822
  updateStudioControls();
@@ -1472,6 +2051,7 @@ async function selectStudioAiChange(filePath) {
1472
2051
  state.studio.activeTab = "editor";
1473
2052
  renderStudio();
1474
2053
  remountStudioEditorIfNeeded();
2054
+ updateRouteFromState();
1475
2055
  updateStudioAiSidebar();
1476
2056
  }
1477
2057
 
@@ -1501,6 +2081,10 @@ async function acceptStudioAiChange(filePath) {
1501
2081
  applyStudioCheckState(result.checkState);
1502
2082
  }
1503
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
+ }
1504
2088
  removeStudioAiChangedFile(change.path);
1505
2089
  if (state.studio.selectedFile?.path === change.path) {
1506
2090
  if (change.status === "deleted") {
@@ -1571,6 +2155,35 @@ function renderStudio() {
1571
2155
  ? "WordPress.org plugin"
1572
2156
  : "Choose a plugin from WordPress.org or Local Library.";
1573
2157
 
2158
+ if (state.studio.loading && !state.studio.scope && state.studio.id) {
2159
+ const fileLabel = state.studio.selectedFile?.path
2160
+ ? `Restoring ${state.studio.selectedFile.path}.`
2161
+ : "Restoring the workspace.";
2162
+ els.studio.innerHTML = `
2163
+ <div class="studio-root studio-empty-root">
2164
+ <div class="studio-empty-state" aria-label="Opening Studio workspace">
2165
+ <section class="studio-empty-copy">
2166
+ <span class="studio-empty-kicker">
2167
+ <span class="dashicons dashicons-editor-code" aria-hidden="true"></span>
2168
+ Pressship Studio
2169
+ </span>
2170
+ <h1>Opening ${escapeHtml(title)}</h1>
2171
+ <p class="studio-empty-subtitle">${escapeHtml(fileLabel)}</p>
2172
+ </section>
2173
+ <section class="studio-empty-panel" aria-label="Loading workspace">
2174
+ ${loadingShell("Loading Studio workspace...")}
2175
+ </section>
2176
+ <div class="studio-empty-footer">
2177
+ <span class="dashicons dashicons-update" aria-hidden="true"></span>
2178
+ Matching the URL to a local or WordPress.org plugin.
2179
+ </div>
2180
+ </div>
2181
+ </div>
2182
+ `;
2183
+ updateStudioControls();
2184
+ return;
2185
+ }
2186
+
1574
2187
  if (!state.studio.id) {
1575
2188
  const pickerOptions = studioPickerOptions();
1576
2189
  const pickerDisabled = pickerOptions.length ? "" : "disabled";
@@ -1632,63 +2245,76 @@ function renderStudio() {
1632
2245
  return;
1633
2246
  }
1634
2247
 
1635
- const fileList = state.studio.files.length
1636
- ? 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))
1637
2251
  : `<p class="studio-muted">No editable text files found.</p>`;
1638
- const editorTabLabel = state.studio.selectedFile?.path ?? "Editor";
1639
2252
  const playgroundPort = state.studio.playgroundUrl ? new URL(state.studio.playgroundUrl).port : "";
2253
+ const panels = normalizeStudioPanelState(state.studio.panels);
1640
2254
 
1641
2255
  els.studio.innerHTML = `
1642
- <div class="studio-root${state.studio.terminalOpen ? " has-terminal" : ""}">
2256
+ <div class="studio-root${state.studio.terminalOpen ? " has-terminal" : ""}${panels.files ? " has-files" : " is-files-collapsed"}${panels.sidebar ? " has-secondary-sidebar" : " is-secondary-sidebar-collapsed"}" data-theme="${escapeAttr(studioTheme())}">
1643
2257
  <header class="studio-titlebar">
1644
2258
  <div class="studio-title">
1645
2259
  <strong>${escapeHtml(title)}</strong>
1646
2260
  <span>${escapeHtml(source)}</span>
1647
2261
  </div>
1648
2262
  <div class="studio-title-actions">
1649
- <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"}">
1650
- <span class="dashicons dashicons-editor-kitchensink" aria-hidden="true"></span>
1651
- <span>Terminal</span>
1652
- </button>
1653
- <button class="studio-action-button" type="button" data-action="studio-save" id="studio-save-button" disabled>
1654
- <span class="dashicons dashicons-saved" aria-hidden="true"></span>
1655
- Save
1656
- </button>
1657
- <button class="studio-action-button" type="button" data-action="studio-check" id="studio-check-button" disabled>
1658
- <span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
1659
- Check
1660
- </button>
1661
- </div>
1662
- </header>
1663
- <div class="studio-main">
1664
- <aside class="studio-files" aria-label="Plugin files">
1665
- <div class="studio-file-list">${fileList}</div>
1666
- </aside>
1667
- ${renderStudioResizer("files", "h", { label: "files panel" })}
1668
- <section class="studio-workbench" aria-label="Studio editor">
1669
- <div class="studio-tabs" aria-label="Studio tabs and Playground controls">
1670
- <div class="studio-tablist" role="tablist" aria-label="Studio tabs">
1671
- <button type="button" role="tab" aria-selected="${state.studio.activeTab === "editor" ? "true" : "false"}" class="studio-tab-button studio-editor-tab${state.studio.activeTab === "editor" ? " is-active" : ""}" data-action="studio-tab" data-tab="editor">
1672
- <span class="dashicons ${studioFileIcon(editorTabLabel)}" aria-hidden="true"></span>
1673
- <span>${escapeHtml(editorTabLabel)}</span>
1674
- ${state.studio.dirty ? `<em aria-label="Unsaved changes"></em>` : ""}
1675
- </button>
1676
- <button type="button" role="tab" aria-selected="${state.studio.activeTab === "home" ? "true" : "false"}" class="studio-tab-button studio-preview-tab${state.studio.activeTab === "home" ? " is-active" : ""}" data-action="studio-tab" data-tab="home">
1677
- <span class="dashicons dashicons-admin-home" aria-hidden="true"></span>
1678
- <span>Home</span>
1679
- ${playgroundPort ? `<small>${escapeHtml(`:${playgroundPort}`)}</small>` : ""}
1680
- </button>
1681
- <button type="button" role="tab" aria-selected="${state.studio.activeTab === "admin" ? "true" : "false"}" class="studio-tab-button studio-preview-tab${state.studio.activeTab === "admin" ? " is-active" : ""}" data-action="studio-tab" data-tab="admin">
1682
- <span class="dashicons dashicons-admin-site-alt3" aria-hidden="true"></span>
1683
- <span>WP Admin</span>
1684
- ${state.studio.playgroundUrl ? `<small>admin/password</small>` : ""}
1685
- </button>
1686
- </div>
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">
1687
2276
  ${renderStudioPlayButton()}
1688
- <span class="studio-preview-state${state.studio.running ? " is-loading" : state.studio.playgroundUrl ? " is-ready" : ""}">
2277
+ <span class="studio-preview-state${state.studio.running ? " is-loading" : state.studio.playgroundUrl ? " is-ready" : ""}" title="${escapeAttr(studioPreviewStateTitle())}">
1689
2278
  <span aria-hidden="true"></span>
1690
- ${escapeHtml(studioPreviewStateLabel())}
2279
+ <em>${escapeHtml(studioPreviewStateLabel())}</em>
1691
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>
2294
+ </div>
2295
+ </header>
2296
+ <div class="studio-main">
2297
+ ${renderStudioActivityBar(panels)}
2298
+ ${
2299
+ panels.files
2300
+ ? `<aside class="studio-files" aria-label="Explorer">
2301
+ <header class="studio-pane-header">
2302
+ <strong>Explorer</strong>
2303
+ <button class="studio-pane-action" type="button" data-action="studio-toggle-files" aria-label="Hide Explorer" title="Hide Explorer">
2304
+ <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
2305
+ </button>
2306
+ </header>
2307
+ <div class="studio-file-list">${fileList}</div>
2308
+ </aside>
2309
+ ${renderStudioResizer("files", "h", { label: "Explorer" })}`
2310
+ : ""
2311
+ }
2312
+ <section class="studio-workbench" aria-label="Studio editor">
2313
+ <div class="studio-tabs" aria-label="Studio tabs and Playground controls">
2314
+ <div class="studio-tablist" role="tablist" aria-label="Studio tabs">
2315
+ ${renderStudioPinnedPreviewTabs(playgroundPort)}
2316
+ ${renderStudioFileTabs()}
2317
+ </div>
1692
2318
  <span class="studio-tab-spacer"></span>
1693
2319
  <span id="studio-editor-status">${escapeHtml(studioEditorStatusLabel())}</span>
1694
2320
  </div>
@@ -1706,23 +2332,202 @@ function renderStudio() {
1706
2332
  <div id="studio-terminal-output" class="studio-terminal-output">
1707
2333
  ${renderStudioTerminal()}
1708
2334
  </div>
1709
- </section>`
2335
+ </section>`
1710
2336
  : ""
1711
2337
  }
1712
2338
  </section>
1713
- ${renderStudioResizer("ai", "h", { invert: true, label: "AI panel" })}
1714
- <aside class="studio-ai" id="studio-ai" aria-label="Studio AI chat">
1715
- ${renderStudioAiSidebar()}
1716
- </aside>
2339
+ ${
2340
+ panels.sidebar
2341
+ ? `${renderStudioResizer("ai", "h", { invert: true, label: "Secondary Side Bar" })}
2342
+ <aside class="studio-ai" id="studio-ai" aria-label="Secondary Side Bar">
2343
+ ${renderStudioAiSidebar()}
2344
+ </aside>`
2345
+ : ""
2346
+ }
1717
2347
  </div>
2348
+ ${renderStudioStatusBar()}
1718
2349
  ${renderStudioPlaygroundVersionModal()}
1719
2350
  </div>
1720
2351
  `;
1721
2352
  applyStudioLayout(els.studio.querySelector(".studio-root"));
1722
2353
  bindStudioResizers();
2354
+ scrollActiveStudioFileTabIntoView();
1723
2355
  updateStudioControls();
1724
2356
  }
1725
2357
 
2358
+ function renderStudioActivityBar(panels) {
2359
+ const tab = state.studio.sidebarTab === "release" ? "release" : "ai";
2360
+ const activityButton = ({ action, icon, label, active, tab: targetTab }) => `
2361
+ <button class="studio-activity-button${active ? " is-active" : ""}" type="button" data-action="${escapeAttr(action)}"${targetTab ? ` data-tab="${escapeAttr(targetTab)}"` : ""} aria-pressed="${active ? "true" : "false"}" aria-label="${escapeAttr(label)}" title="${escapeAttr(label)}">
2362
+ <span class="dashicons ${escapeAttr(icon)}" aria-hidden="true"></span>
2363
+ </button>
2364
+ `;
2365
+
2366
+ return `
2367
+ <nav class="studio-activitybar" aria-label="Studio workbench views">
2368
+ <div class="studio-activitybar-primary">
2369
+ ${activityButton({
2370
+ action: "studio-toggle-files",
2371
+ icon: "dashicons-open-folder",
2372
+ label: panels.files ? "Hide Explorer" : "Show Explorer",
2373
+ active: panels.files
2374
+ })}
2375
+ ${activityButton({
2376
+ action: "studio-tab",
2377
+ icon: "dashicons-editor-code",
2378
+ label: "Editor",
2379
+ active: state.studio.activeTab === "editor",
2380
+ tab: "editor"
2381
+ })}
2382
+ ${activityButton({
2383
+ action: "studio-tab",
2384
+ icon: "dashicons-admin-home",
2385
+ label: "Playground Home",
2386
+ active: state.studio.activeTab === "home",
2387
+ tab: "home"
2388
+ })}
2389
+ </div>
2390
+ <div class="studio-activitybar-secondary">
2391
+ ${activityButton({
2392
+ action: "studio-open-sidebar-tab",
2393
+ icon: "dashicons-format-chat",
2394
+ label: "AI Helper",
2395
+ active: panels.sidebar && tab === "ai",
2396
+ tab: "ai"
2397
+ })}
2398
+ ${activityButton({
2399
+ action: "studio-open-sidebar-tab",
2400
+ icon: "ps-icon-rocket",
2401
+ label: "Release",
2402
+ active: panels.sidebar && tab === "release",
2403
+ tab: "release"
2404
+ })}
2405
+ ${activityButton({
2406
+ action: "studio-toggle-terminal",
2407
+ icon: "dashicons-editor-kitchensink",
2408
+ label: state.studio.terminalOpen ? "Hide Terminal" : "Show Terminal",
2409
+ active: state.studio.terminalOpen
2410
+ })}
2411
+ </div>
2412
+ </nav>
2413
+ `;
2414
+ }
2415
+
2416
+ function renderStudioStatusBar() {
2417
+ const plugin = state.studio.plugin;
2418
+ const file = state.studio.selectedFile?.path ?? "No file selected";
2419
+ const check = state.studio.checkSummary
2420
+ ? `${state.studio.checkSummary.error || 0} errors, ${state.studio.checkSummary.warning || 0} warnings`
2421
+ : "Plugin Check idle";
2422
+ const assistant = selectedStudioAiAssistant();
2423
+ return `
2424
+ <footer class="studio-statusbar" aria-label="Studio status">
2425
+ <span><span class="dashicons dashicons-admin-plugins" aria-hidden="true"></span>${escapeHtml(plugin?.slug ?? state.studio.id ?? "Studio")}</span>
2426
+ <span>${escapeHtml(file)}</span>
2427
+ <span>${escapeHtml(check)}</span>
2428
+ <span class="studio-statusbar-spacer"></span>
2429
+ <span>${escapeHtml(assistant === "none" ? "AI disabled" : assistantLabel(assistant))}</span>
2430
+ <span>${escapeHtml(state.studio.readOnly ? "Read-only" : "Writable")}</span>
2431
+ </footer>
2432
+ `;
2433
+ }
2434
+
2435
+ function renderStudioPinnedPreviewTabs(playgroundPort) {
2436
+ return `
2437
+ <span class="studio-pinned-tabs" aria-label="Pinned preview tabs">
2438
+ <button type="button" role="tab" aria-selected="${state.studio.activeTab === "home" ? "true" : "false"}" class="studio-tab-button studio-tab-pinned studio-preview-tab${state.studio.activeTab === "home" ? " is-active" : ""}" data-action="studio-tab" data-tab="home" title="Home">
2439
+ <span class="dashicons dashicons-admin-home" aria-hidden="true"></span>
2440
+ <span>Home</span>
2441
+ ${playgroundPort ? `<small>${escapeHtml(`:${playgroundPort}`)}</small>` : ""}
2442
+ </button>
2443
+ <button type="button" role="tab" aria-selected="${state.studio.activeTab === "admin" ? "true" : "false"}" class="studio-tab-button studio-tab-pinned studio-preview-tab${state.studio.activeTab === "admin" ? " is-active" : ""}" data-action="studio-tab" data-tab="admin" title="WP Admin">
2444
+ <span class="dashicons dashicons-admin-site-alt3" aria-hidden="true"></span>
2445
+ <span>WP Admin</span>
2446
+ ${state.studio.playgroundUrl ? `<small>admin/password</small>` : ""}
2447
+ </button>
2448
+ </span>
2449
+ `;
2450
+ }
2451
+
2452
+ function renderStudioFileTabs() {
2453
+ const openFiles = studioOpenFileTabs();
2454
+ if (!openFiles.length) {
2455
+ return `<span class="studio-file-tabs-empty">Open a file from Explorer</span>`;
2456
+ }
2457
+ return `
2458
+ <span class="studio-file-tabs" aria-label="Open files">
2459
+ ${openFiles.map((file) => renderStudioFileTab(file)).join("")}
2460
+ </span>
2461
+ `;
2462
+ }
2463
+
2464
+ function studioOpenFileTabs() {
2465
+ const knownFiles = new Map(state.studio.files.map((file) => [file.path, file]));
2466
+ const selectedPath = state.studio.selectedFile?.path;
2467
+ const paths = Array.isArray(state.studio.openFiles) ? [...state.studio.openFiles] : [];
2468
+ if (selectedPath && !paths.includes(selectedPath)) {
2469
+ paths.push(selectedPath);
2470
+ }
2471
+ state.studio.openFiles = paths;
2472
+ return paths.map((path) => knownFiles.get(path) ?? {
2473
+ path,
2474
+ name: path.split("/").pop() ?? path,
2475
+ directory: path.includes("/") ? path.split("/").slice(0, -1).join("/") : "",
2476
+ size: 0
2477
+ });
2478
+ }
2479
+
2480
+ function renderStudioFileTab(file) {
2481
+ const current = state.studio.activeTab === "editor" && file.path === state.studio.selectedFile?.path;
2482
+ const dirty = current && state.studio.dirty;
2483
+ return `
2484
+ <span class="studio-file-tab-wrap">
2485
+ <button type="button" role="tab" aria-selected="${current ? "true" : "false"}" class="studio-tab-button studio-editor-tab${current ? " is-active" : ""}" data-action="studio-file" data-path="${escapeAttr(file.path)}" title="${escapeAttr(file.path)}">
2486
+ <span class="dashicons ${studioFileIcon(file.path)}" aria-hidden="true"></span>
2487
+ <span>${escapeHtml(file.name ?? file.path)}</span>
2488
+ ${dirty ? `<em aria-label="Unsaved changes"></em>` : ""}
2489
+ </button>
2490
+ <button type="button" class="studio-tab-close" data-action="studio-close-file-tab" data-path="${escapeAttr(file.path)}" aria-label="Close ${escapeAttr(file.name ?? file.path)}" title="Close">
2491
+ <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
2492
+ </button>
2493
+ </span>
2494
+ `;
2495
+ }
2496
+
2497
+ function scrollActiveStudioFileTabIntoView() {
2498
+ if (state.studio.activeTab !== "editor" || !state.studio.selectedFile?.path) {
2499
+ return;
2500
+ }
2501
+ requestAnimationFrame(() => {
2502
+ const container = document.querySelector(".studio-file-tabs");
2503
+ if (!container) {
2504
+ return;
2505
+ }
2506
+ const selectedPath = state.studio.selectedFile?.path;
2507
+ const activeTab = Array.from(container.querySelectorAll(".studio-tab-button[data-path]")).find(
2508
+ (button) => button.dataset.path === selectedPath
2509
+ );
2510
+ const target = activeTab?.closest(".studio-file-tab-wrap") ?? activeTab;
2511
+ if (!target) {
2512
+ return;
2513
+ }
2514
+
2515
+ const padding = 12;
2516
+ const containerRect = container.getBoundingClientRect();
2517
+ const targetRect = target.getBoundingClientRect();
2518
+ const targetLeft = targetRect.left - containerRect.left + container.scrollLeft;
2519
+ const targetRight = targetLeft + targetRect.width;
2520
+ const visibleLeft = container.scrollLeft;
2521
+ const visibleRight = visibleLeft + container.clientWidth;
2522
+
2523
+ if (targetLeft < visibleLeft + padding) {
2524
+ container.scrollLeft = Math.max(0, targetLeft - padding);
2525
+ } else if (targetRight > visibleRight - padding) {
2526
+ container.scrollLeft = targetRight - container.clientWidth + padding;
2527
+ }
2528
+ });
2529
+ }
2530
+
1726
2531
  function renderStudioPlaygroundVersionModal() {
1727
2532
  const modal = state.studio.playgroundVersionModal;
1728
2533
  if (!modal) {
@@ -1879,14 +2684,101 @@ function renderStudioPlayButton() {
1879
2684
  `;
1880
2685
  }
1881
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
+
2749
+ function renderStudioThemeToggle() {
2750
+ const theme = studioTheme();
2751
+ const isLight = theme === "light";
2752
+ const nextTheme = isLight ? "dark" : "light";
2753
+ return `
2754
+ <button class="studio-theme-switch" type="button" role="switch" aria-checked="${isLight ? "true" : "false"}" data-action="studio-toggle-theme" title="${escapeAttr(`Switch to ${nextTheme} mode`)}">
2755
+ <span class="dashicons dashicons-admin-appearance" aria-hidden="true"></span>
2756
+ <span class="studio-theme-switch-track" aria-hidden="true">
2757
+ <span></span>
2758
+ </span>
2759
+ <span class="studio-theme-switch-label">${escapeHtml(isLight ? "Light" : "Dark")}</span>
2760
+ </button>
2761
+ `;
2762
+ }
2763
+
1882
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() {
1883
2775
  if (state.studio.running) {
1884
2776
  return "Starting Playground";
1885
2777
  }
1886
2778
  if (state.studio.playgroundUrl) {
1887
2779
  return "Playground ready";
1888
2780
  }
1889
- return "Not started";
2781
+ return "Playground not started";
1890
2782
  }
1891
2783
 
1892
2784
  function studioEditorStatusLabel() {
@@ -1906,10 +2798,62 @@ function switchStudioTab(tab) {
1906
2798
  if (!["editor", "home", "admin"].includes(tab)) {
1907
2799
  return;
1908
2800
  }
1909
- captureStudioEditorValue();
2801
+ const discardingDirtyEditor =
2802
+ state.studio.activeTab === "editor" &&
2803
+ tab !== "editor" &&
2804
+ state.studio.dirty;
2805
+ if (discardingDirtyEditor) {
2806
+ if (!confirm("Discard unsaved changes in the current file?")) {
2807
+ return;
2808
+ }
2809
+ state.studio.draftContent = state.studio.fileContent;
2810
+ state.studio.dirty = false;
2811
+ } else {
2812
+ captureStudioEditorValue();
2813
+ }
1910
2814
  state.studio.activeTab = tab;
1911
2815
  renderStudio();
1912
2816
  remountStudioEditorIfNeeded();
2817
+ updateRouteFromState();
2818
+ }
2819
+
2820
+ async function closeStudioFileTab(relativePath) {
2821
+ if (!relativePath) {
2822
+ return;
2823
+ }
2824
+ const openFiles = Array.isArray(state.studio.openFiles) ? state.studio.openFiles : [];
2825
+ const index = openFiles.indexOf(relativePath);
2826
+ if (index === -1) {
2827
+ return;
2828
+ }
2829
+ const isCurrent = relativePath === state.studio.selectedFile?.path;
2830
+ if (isCurrent && state.studio.dirty && !confirm("Discard unsaved changes in the current file?")) {
2831
+ return;
2832
+ }
2833
+
2834
+ captureStudioEditorValue();
2835
+ const nextOpenFiles = openFiles.filter((path) => path !== relativePath);
2836
+ state.studio.openFiles = nextOpenFiles;
2837
+
2838
+ if (!isCurrent) {
2839
+ renderStudio();
2840
+ remountStudioEditorIfNeeded();
2841
+ return;
2842
+ }
2843
+
2844
+ state.studio.dirty = false;
2845
+ const nextPath = nextOpenFiles[Math.min(index, nextOpenFiles.length - 1)];
2846
+ if (nextPath) {
2847
+ await selectStudioFile(nextPath, { force: true });
2848
+ return;
2849
+ }
2850
+
2851
+ state.studio.selectedFile = null;
2852
+ state.studio.fileContent = "";
2853
+ state.studio.draftContent = "";
2854
+ state.studio.activeTab = "home";
2855
+ renderStudio();
2856
+ updateRouteFromState();
1913
2857
  }
1914
2858
 
1915
2859
  function toggleStudioTerminal() {
@@ -1919,22 +2863,166 @@ function toggleStudioTerminal() {
1919
2863
  remountStudioEditorIfNeeded();
1920
2864
  }
1921
2865
 
1922
- function toggleStudioFolder(folderPath) {
2866
+ function toggleStudioPanel(panel) {
2867
+ if (!["files", "sidebar"].includes(panel)) {
2868
+ return;
2869
+ }
2870
+ captureStudioEditorValue();
2871
+ state.studio.panels = normalizeStudioPanelState(state.studio.panels);
2872
+ state.studio.panels[panel] = !state.studio.panels[panel];
2873
+ saveStudioPanelState(state.studio.panels);
2874
+ renderStudio();
2875
+ remountStudioEditorIfNeeded();
2876
+ requestAnimationFrame(() => state.studio.editor?.layout?.());
2877
+ }
2878
+
2879
+ function openStudioSidebarTab(tab) {
2880
+ captureStudioEditorValue();
2881
+ state.studio.panels = normalizeStudioPanelState(state.studio.panels);
2882
+ state.studio.panels.sidebar = true;
2883
+ saveStudioPanelState(state.studio.panels);
2884
+ setStudioSidebarTab(tab);
2885
+ renderStudio();
2886
+ remountStudioEditorIfNeeded();
2887
+ requestAnimationFrame(() => state.studio.editor?.layout?.());
2888
+ }
2889
+
2890
+ function toggleStudioTheme() {
2891
+ captureStudioEditorValue();
2892
+ state.studio.theme = studioTheme() === "light" ? "dark" : "light";
2893
+ saveStudioTheme(state.studio.theme);
2894
+ renderStudio();
2895
+ remountStudioEditorIfNeeded();
2896
+ requestAnimationFrame(() => state.studio.editor?.layout?.());
2897
+ }
2898
+
2899
+ async function toggleStudioFolder(folderPath) {
1923
2900
  if (!folderPath) {
1924
2901
  return;
1925
2902
  }
2903
+ const normalized = String(folderPath ?? "").replace(/\/+$/, "");
1926
2904
  captureStudioEditorValue();
1927
- if (state.studio.collapsedFolders.has(folderPath)) {
1928
- 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);
1929
2936
  } else {
1930
- state.studio.collapsedFolders.add(folderPath);
2937
+ state.studio.collapsedFolders.add(normalized);
1931
2938
  }
1932
2939
  renderStudio();
1933
2940
  remountStudioEditorIfNeeded();
1934
2941
  }
1935
2942
 
1936
- function buildStudioFileTree(files) {
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;
2950
+ }
2951
+
2952
+ state.studio.loadingFolders.add(normalized);
2953
+ renderStudio();
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);
3000
+ }
3001
+
3002
+ function buildStudioFileTree(files, directories = []) {
1937
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
+
1938
3026
  for (const file of files) {
1939
3027
  const parts = file.path.split("/").filter(Boolean);
1940
3028
  let node = root;
@@ -1978,19 +3066,48 @@ function renderStudioTreeChildren(children, depth) {
1978
3066
 
1979
3067
  function renderStudioTreeNode(node, depth) {
1980
3068
  if (node.type === "folder") {
1981
- 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);
1982
3072
  const containsCurrent = Boolean(
1983
3073
  state.studio.selectedFile?.path && state.studio.selectedFile.path.startsWith(`${node.path}/`)
1984
3074
  );
1985
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";
1986
3089
  return `
1987
- <div class="studio-tree-folder${containsCurrent ? " has-current" : ""}${containsAiChange ? " has-ai-changes" : ""}" role="treeitem" aria-expanded="${collapsed ? "false" : "true"}">
1988
- <button type="button" class="studio-tree-row studio-tree-folder-row" data-action="studio-toggle-folder" data-folder="${escapeAttr(node.path)}" style="--depth:${depth}">
1989
- <span class="dashicons ${collapsed ? "dashicons-arrow-right-alt2" : "dashicons-arrow-down-alt2"} studio-tree-arrow" aria-hidden="true"></span>
1990
- <span class="dashicons ${collapsed ? "dashicons-category" : "dashicons-open-folder"} studio-tree-icon" aria-hidden="true"></span>
1991
- <span class="studio-tree-label">${escapeHtml(node.name)}</span>
1992
- ${containsAiChange ? `<span class="studio-tree-ai-badge" title="AI patches inside this folder">AI</span>` : ""}
1993
- </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>
1994
3111
  ${collapsed ? "" : `<div role="group">${renderStudioTreeChildren(node.children, depth + 1)}</div>`}
1995
3112
  </div>
1996
3113
  `;
@@ -1999,6 +3116,12 @@ function renderStudioTreeNode(node, depth) {
1999
3116
  const current = node.path === state.studio.selectedFile?.path;
2000
3117
  const checkCounts = studioCheckCountsForPath(node.path);
2001
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;
2002
3125
  const checkBadge = checkCounts.total
2003
3126
  ? `<span class="studio-tree-check-badge${checkCounts.error ? " has-errors" : ""}" title="${escapeAttr(formatCheckCounts(checkCounts))}">${escapeHtml(String(checkCounts.total))}</span>`
2004
3127
  : "";
@@ -2006,11 +3129,43 @@ function renderStudioTreeNode(node, depth) {
2006
3129
  ? `<span class="studio-tree-ai-badge" title="${escapeAttr(`AI proposed ${aiChange.status} patch`)}">AI</span>`
2007
3130
  : "";
2008
3131
  return `
2009
- <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}">
2010
- <span class="studio-tree-indent" aria-hidden="true"></span>
2011
- <span class="dashicons ${studioFileIcon(node.path)} studio-tree-icon" aria-hidden="true"></span>
2012
- <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>
2013
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>
2014
3169
  </button>
2015
3170
  `;
2016
3171
  }
@@ -2027,13 +3182,19 @@ function renderStudioAiSidebar() {
2027
3182
 
2028
3183
  function renderStudioSidebarTabs(activeTab) {
2029
3184
  return `
3185
+ <header class="studio-secondary-header">
3186
+ <strong>${activeTab === "release" ? "Release" : "AI Helper"}</strong>
3187
+ <button class="studio-pane-action" type="button" data-action="studio-toggle-sidebar" aria-label="Hide Secondary Side Bar" title="Hide Secondary Side Bar">
3188
+ <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
3189
+ </button>
3190
+ </header>
2030
3191
  <div class="studio-sidebar-tabs ps-segmented" role="tablist" aria-label="Studio sidebar">
2031
3192
  <button class="ps-segmented-option${activeTab === "ai" ? " is-active" : ""}" type="button" role="tab" aria-selected="${activeTab === "ai"}" data-action="studio-sidebar-tab" data-tab="ai">
2032
3193
  <span class="dashicons dashicons-format-chat" aria-hidden="true"></span>
2033
3194
  AI Helper
2034
3195
  </button>
2035
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">
2036
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
3197
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
2037
3198
  Release
2038
3199
  </button>
2039
3200
  </div>
@@ -2245,7 +3406,7 @@ function renderStudioAiMessages() {
2245
3406
  <span>${escapeHtml(aiMessageRoleLabel(message))}</span>
2246
3407
  <time>${escapeHtml(formatTime(message.createdAt))}</time>
2247
3408
  </header>
2248
- <p>${escapeHtml(message.text)}</p>
3409
+ <div class="studio-ai-markdown">${renderStudioAiMarkdown(message.text)}</div>
2249
3410
  </div>
2250
3411
  </article>
2251
3412
  `
@@ -2623,69 +3784,479 @@ function appendStudioTerminal(message, tone = "muted") {
2623
3784
  }
2624
3785
  }
2625
3786
 
2626
- function selectedStudioAiAssistant() {
2627
- return state.settings?.aiAssistant ?? "none";
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
+
3916
+ function selectedStudioAiAssistant() {
3917
+ return state.settings?.aiAssistant ?? "none";
3918
+ }
3919
+
3920
+ function assistantLabel(id) {
3921
+ const provider = aiAssistanceProviders().find((item) => item.id === id);
3922
+ if (provider) {
3923
+ return provider.label;
3924
+ }
3925
+
3926
+ const labels = {
3927
+ none: "AI",
3928
+ claude: "Claude Code",
3929
+ codex: "Codex CLI",
3930
+ copilot: "GitHub Copilot CLI",
3931
+ cursor: "Cursor",
3932
+ gemini: "Gemini CLI",
3933
+ opencode: "OpenCode",
3934
+ "wp-studio": "WP Studio"
3935
+ };
3936
+ return labels[id] ?? capitalize(String(id));
3937
+ }
3938
+
3939
+ function aiMessageRoleLabel(message) {
3940
+ if (message.role === "user") return "You";
3941
+ if (message.role === "assistant") return assistantLabel(message.assistant ?? state.studio.aiActiveAssistant ?? selectedStudioAiAssistant());
3942
+ return "Studio";
3943
+ }
3944
+
3945
+ function canRunStudioAi() {
3946
+ return (
3947
+ state.studio.scope === "local" &&
3948
+ Boolean(state.studio.id) &&
3949
+ !state.studio.loading &&
3950
+ !state.studio.aiRunning &&
3951
+ selectedStudioAiAssistant() !== "none"
3952
+ );
3953
+ }
3954
+
3955
+ function studioAiDisabledReason() {
3956
+ if (state.studio.scope !== "local") {
3957
+ return "Open a local plugin to use AI.";
3958
+ }
3959
+ if (selectedStudioAiAssistant() === "none") {
3960
+ return "Select AI Assistance in Settings.";
3961
+ }
3962
+ if (state.studio.aiRunning) {
3963
+ return "";
3964
+ }
3965
+ return "";
3966
+ }
3967
+
3968
+ function applyStudioCheckState(checkState) {
3969
+ if (!checkState) {
3970
+ state.studio.checkFindings = [];
3971
+ state.studio.checkSummary = null;
3972
+ state.studio.checkRanAt = null;
3973
+ return;
3974
+ }
3975
+
3976
+ state.studio.checkFindings = checkState.findings ?? [];
3977
+ state.studio.checkSummary = checkState.summary ?? null;
3978
+ state.studio.checkRanAt = checkState.checkedAt ?? null;
3979
+ }
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}/**` : "";
2628
4165
  }
2629
4166
 
2630
- function assistantLabel(id) {
2631
- const provider = aiAssistanceProviders().find((item) => item.id === id);
2632
- if (provider) {
2633
- return provider.label;
4167
+ function studioIgnoredFilesForPrefix(prefix) {
4168
+ const normalized = String(prefix ?? "").replace(/\/+$/, "");
4169
+ if (!normalized) {
4170
+ return [];
2634
4171
  }
2635
4172
 
2636
- const labels = {
2637
- none: "AI",
2638
- claude: "Claude Code",
2639
- codex: "Codex CLI",
2640
- copilot: "GitHub Copilot CLI",
2641
- cursor: "Cursor",
2642
- gemini: "Gemini CLI",
2643
- opencode: "OpenCode",
2644
- "wp-studio": "WP Studio"
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
+ }
2645
4184
  };
2646
- return labels[id] ?? capitalize(String(id));
2647
- }
2648
4185
 
2649
- function aiMessageRoleLabel(message) {
2650
- if (message.role === "user") return "You";
2651
- if (message.role === "assistant") return assistantLabel(message.assistant ?? state.studio.aiActiveAssistant ?? selectedStudioAiAssistant());
2652
- return "Studio";
2653
- }
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
+ }
2654
4194
 
2655
- function canRunStudioAi() {
2656
- return (
2657
- state.studio.scope === "local" &&
2658
- Boolean(state.studio.id) &&
2659
- !state.studio.loading &&
2660
- !state.studio.aiRunning &&
2661
- selectedStudioAiAssistant() !== "none"
2662
- );
4195
+ return files;
2663
4196
  }
2664
4197
 
2665
- function studioAiDisabledReason() {
2666
- if (state.studio.scope !== "local") {
2667
- return "Open a local plugin to use AI.";
2668
- }
2669
- if (selectedStudioAiAssistant() === "none") {
2670
- return "Select AI Assistance in Settings.";
4198
+ async function addStudioIgnoreRule(pattern, options = {}) {
4199
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
4200
+ return;
2671
4201
  }
2672
- if (state.studio.aiRunning) {
2673
- return "";
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();
2674
4228
  }
2675
- return "";
2676
4229
  }
2677
4230
 
2678
- function applyStudioCheckState(checkState) {
2679
- if (!checkState) {
2680
- state.studio.checkFindings = [];
2681
- state.studio.checkSummary = null;
2682
- state.studio.checkRanAt = null;
4231
+ async function removeStudioIgnoreRule(pattern) {
4232
+ if (!pattern || state.studio.scope !== "local" || !state.studio.id) {
2683
4233
  return;
2684
4234
  }
2685
4235
 
2686
- state.studio.checkFindings = checkState.findings ?? [];
2687
- state.studio.checkSummary = checkState.summary ?? null;
2688
- state.studio.checkRanAt = checkState.checkedAt ?? null;
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
+ }
2689
4260
  }
2690
4261
 
2691
4262
  function appendStudioAiMessage(role, text, tone = "muted") {
@@ -2711,7 +4282,7 @@ function appendStudioAiOutput(text, tone = "log") {
2711
4282
  }
2712
4283
  last.text = `${last.text}${output}`;
2713
4284
  last.tone = tone === "error" ? "error" : last.tone === "status" ? "log" : last.tone;
2714
- updateStudioAiSidebar();
4285
+ updateStudioAiMessageList();
2715
4286
  }
2716
4287
 
2717
4288
  function updateStudioAiSidebar() {
@@ -2723,11 +4294,40 @@ function updateStudioAiSidebar() {
2723
4294
  node.innerHTML = renderStudioAiSidebar();
2724
4295
  const messages = document.getElementById("studio-ai-messages");
2725
4296
  if (messages) {
2726
- messages.scrollTop = messages.scrollHeight;
4297
+ scrollStudioAiMessagesToBottom(messages);
4298
+ }
4299
+ updateStudioAiControls();
4300
+ }
4301
+
4302
+ function updateStudioAiMessageList(options = {}) {
4303
+ const messages = document.getElementById("studio-ai-messages");
4304
+ if (!messages) {
4305
+ updateStudioAiSidebar();
4306
+ return;
4307
+ }
4308
+
4309
+ const shouldStick = options.forceScroll || isStudioAiMessagesNearBottom(messages);
4310
+ messages.innerHTML = renderStudioAiMessages();
4311
+ if (shouldStick) {
4312
+ scrollStudioAiMessagesToBottom(messages);
2727
4313
  }
2728
4314
  updateStudioAiControls();
2729
4315
  }
2730
4316
 
4317
+ function isStudioAiMessagesNearBottom(messages) {
4318
+ return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
4319
+ }
4320
+
4321
+ function scrollStudioAiMessagesToBottom(messages) {
4322
+ const previousScrollBehavior = messages.style.scrollBehavior;
4323
+ messages.style.scrollBehavior = "auto";
4324
+ messages.scrollTop = messages.scrollHeight;
4325
+ requestAnimationFrame(() => {
4326
+ messages.scrollTop = messages.scrollHeight;
4327
+ messages.style.scrollBehavior = previousScrollBehavior;
4328
+ });
4329
+ }
4330
+
2731
4331
  function updateStudioAiControls() {
2732
4332
  const prompt = document.getElementById("studio-ai-prompt");
2733
4333
  const send = document.getElementById("studio-ai-send-button");
@@ -2994,6 +4594,16 @@ function chooseInitialStudioFile(files, slug) {
2994
4594
  );
2995
4595
  }
2996
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
+
2997
4607
  function updateStudioControls() {
2998
4608
  const canRun = Boolean(state.studio.id) && !state.studio.loading && !state.studio.running;
2999
4609
  const canCheck =
@@ -3015,12 +4625,17 @@ function updateStudioControls() {
3015
4625
  }
3016
4626
  if (studioCheck) {
3017
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";
3018
4630
  studioCheck.innerHTML = state.studio.checking
3019
- ? `<span class="dashicons dashicons-update" aria-hidden="true"></span> Checking…`
3020
- : `<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>`;
3021
4633
  }
3022
4634
  if (studioSave) {
3023
- 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());
3024
4639
  }
3025
4640
  updateStudioAiControls();
3026
4641
  }
@@ -3078,7 +4693,7 @@ async function mountStudioEditor(content) {
3078
4693
  );
3079
4694
  state.studio.editorModels = [originalModel, modifiedModel];
3080
4695
  state.studio.editor = monaco.editor.createDiffEditor(container, {
3081
- theme: "vs-dark",
4696
+ theme: studioMonacoTheme(),
3082
4697
  readOnly: true,
3083
4698
  automaticLayout: true,
3084
4699
  minimap: { enabled: false },
@@ -3101,7 +4716,7 @@ async function mountStudioEditor(content) {
3101
4716
  state.studio.editor = monaco.editor.create(container, {
3102
4717
  value: content,
3103
4718
  language: languageForPath(state.studio.selectedFile?.path ?? ""),
3104
- theme: "vs-dark",
4719
+ theme: studioMonacoTheme(),
3105
4720
  readOnly: state.studio.readOnly,
3106
4721
  automaticLayout: true,
3107
4722
  minimap: { enabled: false },
@@ -3112,6 +4727,11 @@ async function mountStudioEditor(content) {
3112
4727
  scrollBeyondLastLine: false
3113
4728
  });
3114
4729
  state.studio.editorKind = "monaco";
4730
+ state.studio.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
4731
+ if (canSaveStudioFile()) {
4732
+ void saveStudioFile();
4733
+ }
4734
+ });
3115
4735
  state.studio.editor.onDidChangeModelContent(() => {
3116
4736
  state.studio.draftContent = getStudioEditorValue();
3117
4737
  state.studio.dirty = state.studio.draftContent !== state.studio.fileContent;
@@ -3323,6 +4943,7 @@ function getStudioEditorValue() {
3323
4943
 
3324
4944
  function ensureMonaco() {
3325
4945
  if (window.monaco) {
4946
+ configurePressshipMonaco(window.monaco);
3326
4947
  return Promise.resolve(window.monaco);
3327
4948
  }
3328
4949
  if (monacoPromise) {
@@ -3338,7 +4959,10 @@ function ensureMonaco() {
3338
4959
  vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs"
3339
4960
  }
3340
4961
  });
3341
- 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);
3342
4966
  };
3343
4967
  script.onerror = () => reject(new Error("Could not load Monaco Editor."));
3344
4968
  document.head.appendChild(script);
@@ -3347,7 +4971,132 @@ function ensureMonaco() {
3347
4971
  return monacoPromise;
3348
4972
  }
3349
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
+
3350
5095
  function languageForPath(filePath) {
5096
+ const fileName = String(filePath ?? "").split("/").pop()?.toLowerCase();
5097
+ if (fileName === "readme.txt") {
5098
+ return "wordpress-readme";
5099
+ }
3351
5100
  const ext = filePath.split(".").pop()?.toLowerCase();
3352
5101
  const map = {
3353
5102
  css: "css",
@@ -3900,7 +5649,7 @@ function localCard(plugin, versionState) {
3900
5649
  Details
3901
5650
  </button>
3902
5651
  <button type="button" role="menuitem" data-action="manage-release" data-id="${escapeAttr(plugin.id)}">
3903
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5652
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
3904
5653
  Manage release
3905
5654
  </button>
3906
5655
  <button type="button" role="menuitem" data-action="version-state" data-id="${escapeAttr(plugin.id)}">
@@ -3936,7 +5685,7 @@ function localCard(plugin, versionState) {
3936
5685
  Open in Studio
3937
5686
  </button>
3938
5687
  <button type="button" class="ps-plugin-card-secondary" data-action="manage-release" data-id="${escapeAttr(plugin.id)}" ${missing ? "disabled" : ""}>
3939
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5688
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
3940
5689
  Manage release
3941
5690
  </button>
3942
5691
  </footer>
@@ -3949,6 +5698,12 @@ function localCard(plugin, versionState) {
3949
5698
  * =================================================================== */
3950
5699
 
3951
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
+ }
3952
5707
  els.detail.classList.add("is-open");
3953
5708
  els.detail.setAttribute("aria-hidden", "false");
3954
5709
  els.detail.innerHTML = `
@@ -4051,6 +5806,7 @@ function closeDetail() {
4051
5806
  * =================================================================== */
4052
5807
 
4053
5808
  async function createJob(body) {
5809
+ appendStudioCliCommand(studioCliCommandForJob(body));
4054
5810
  const job = await api("/api/jobs", { method: "POST", body });
4055
5811
  upsertJob(job);
4056
5812
  subscribeJob(job.id);
@@ -4784,36 +6540,35 @@ function configureAutoRefresh() {
4784
6540
  * View switching with view-transitions
4785
6541
  * =================================================================== */
4786
6542
 
4787
- function showView(view) {
4788
- if (state.activeView === view) {
4789
- return;
6543
+ function showView(view, options = {}) {
6544
+ const nextView = normalizeViewId(view);
6545
+ if (state.activeView === nextView) {
6546
+ if (options.updateRoute !== false) {
6547
+ updateRouteFromState({ replace: options.replaceRoute });
6548
+ }
6549
+ return Promise.resolve();
4790
6550
  }
4791
6551
  const apply = () => {
4792
- state.activeView = view;
4793
- document.body.dataset.activeView = view;
4794
- document.querySelectorAll(".view").forEach((node) => node.classList.remove("is-active"));
4795
- document.getElementById(`view-${view}`)?.classList.add("is-active");
4796
- document
4797
- .querySelectorAll("#adminmenu li")
4798
- .forEach((node) => node.classList.remove("wp-has-current-submenu"));
4799
- document
4800
- .querySelector(`#adminmenu li[data-view="${view}"]`)
4801
- ?.classList.add("wp-has-current-submenu");
6552
+ applyActiveViewShell(nextView);
4802
6553
  closeDetail();
4803
- if (view === "release") {
6554
+ if (nextView === "release") {
4804
6555
  if (!state.releaseBoard.loading && !state.releaseBoard.plugins.length) {
4805
6556
  void loadReleaseBoard();
4806
6557
  } else {
4807
6558
  renderReleaseBoard();
4808
6559
  }
4809
6560
  }
6561
+ if (options.updateRoute !== false) {
6562
+ updateRouteFromState({ replace: options.replaceRoute });
6563
+ }
4810
6564
  };
4811
6565
 
4812
6566
  if (typeof document.startViewTransition === "function") {
4813
- document.startViewTransition(apply);
4814
- } else {
4815
- apply();
6567
+ const transition = document.startViewTransition(apply);
6568
+ return transition.updateCallbackDone?.catch(() => {}) ?? Promise.resolve();
4816
6569
  }
6570
+ apply();
6571
+ return Promise.resolve();
4817
6572
  }
4818
6573
 
4819
6574
  /* ===================================================================
@@ -4854,7 +6609,7 @@ function commandItems() {
4854
6609
  id: "view:release",
4855
6610
  title: "Go to Release Management",
4856
6611
  subtitle: "Release status for every local plugin",
4857
- icon: "dashicons-update",
6612
+ icon: "ps-icon-rocket",
4858
6613
  run: () => showView("release")
4859
6614
  },
4860
6615
  {
@@ -4914,7 +6669,7 @@ function commandItems() {
4914
6669
  id: `release:${plugin.id}`,
4915
6670
  title: `Manage release • ${plugin.name}`,
4916
6671
  subtitle: plugin.slug,
4917
- icon: "dashicons-update",
6672
+ icon: "ps-icon-rocket",
4918
6673
  run: () => {
4919
6674
  void openStudio("local", plugin.id, { sidebarTab: "release" });
4920
6675
  }
@@ -5215,6 +6970,16 @@ function renderStudioAiMarkdown(value) {
5215
6970
  }
5216
6971
  }
5217
6972
 
6973
+ function refreshStudioAiMarkdownIfReady() {
6974
+ try {
6975
+ if ((state.studio.aiMessages ?? []).some((message) => message.role === "assistant")) {
6976
+ updateStudioAiMessageList();
6977
+ }
6978
+ } catch {
6979
+ // The markdown parser can finish loading before Studio state exists.
6980
+ }
6981
+ }
6982
+
5218
6983
  function basicMarkdownToHtml(value) {
5219
6984
  const lines = String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
5220
6985
  const blocks = [];
@@ -5460,6 +7225,22 @@ function isSafeMarkdownHref(value) {
5460
7225
  }
5461
7226
  }
5462
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
+
5463
7244
  function escapeHtml(value) {
5464
7245
  return String(value)
5465
7246
  .replaceAll("&", "&amp;")
@@ -5572,7 +7353,7 @@ function renderReleaseBoard() {
5572
7353
  els.release.innerHTML = emptyState({
5573
7354
  title: "No local plugins to release.",
5574
7355
  message: "Add a plugin folder to your Local Library, then come back here.",
5575
- icon: "dashicons-update"
7356
+ icon: "ps-icon-rocket"
5576
7357
  });
5577
7358
  return;
5578
7359
  }
@@ -5581,7 +7362,7 @@ function renderReleaseBoard() {
5581
7362
  els.release.innerHTML = `
5582
7363
  <div class="ps-card-toolbar" role="region" aria-label="Release board summary">
5583
7364
  <span class="ps-card-toolbar-count">
5584
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7365
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5585
7366
  ${escapeHtml(
5586
7367
  `${state.releaseBoard.plugins.length} plugin${state.releaseBoard.plugins.length === 1 ? "" : "s"} tracked`
5587
7368
  )}
@@ -5624,7 +7405,7 @@ function releaseBoardCard(entry) {
5624
7405
  : ""}
5625
7406
  <footer class="ps-release-board-card-footer">
5626
7407
  <button type="button" class="button button-primary" data-action="manage-release" data-id="${escapeAttr(entry.id)}" ${entry.exists === false ? "disabled" : ""}>
5627
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7408
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5628
7409
  Manage release
5629
7410
  </button>
5630
7411
  <button type="button" class="ps-plugin-card-secondary" data-action="version-state" data-id="${escapeAttr(entry.id)}" ${entry.exists === false ? "disabled" : ""}>
@@ -5649,15 +7430,15 @@ function studioPluginKey() {
5649
7430
 
5650
7431
  function setStudioSidebarTab(tab) {
5651
7432
  const next = tab === "release" ? "release" : "ai";
5652
- if (state.studio.sidebarTab === next) {
5653
- return;
5654
- }
5655
7433
  state.studio.sidebarTab = next;
5656
7434
  saveStudioSidebarTab(studioPluginKey(), next);
5657
7435
  updateStudioSidebar();
5658
7436
  if (next === "release" && state.studio.scope === "local" && !state.studio.release.tags) {
5659
7437
  void loadStudioReleaseTags();
5660
7438
  }
7439
+ if (next === "release" && state.studio.scope === "local") {
7440
+ void refreshStudioIgnoreState({ files: true });
7441
+ }
5661
7442
  }
5662
7443
 
5663
7444
  function updateStudioSidebar() {
@@ -5682,7 +7463,7 @@ function renderStudioReleasePane() {
5682
7463
  return `
5683
7464
  <div class="studio-release-pane">
5684
7465
  <div class="studio-release-empty">
5685
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7466
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5686
7467
  <strong>Release management is local-only</strong>
5687
7468
  <p>Open a local plugin to manage its release lifecycle.</p>
5688
7469
  </div>
@@ -5693,7 +7474,7 @@ function renderStudioReleasePane() {
5693
7474
  return `
5694
7475
  <div class="studio-release-pane">
5695
7476
  <div class="studio-release-empty">
5696
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
7477
+ <span class="dashicons ps-icon-rocket" aria-hidden="true"></span>
5697
7478
  <strong>Open a plugin to start</strong>
5698
7479
  </div>
5699
7480
  </div>
@@ -5715,8 +7496,9 @@ function renderStudioReleasePane() {
5715
7496
  </header>
5716
7497
  <ol class="ps-release-funnel">
5717
7498
  ${renderStudioReleaseStepVersion(versionState, release)}
5718
- ${renderStudioReleaseStepTags(release)}
7499
+ ${renderStudioReleaseStepTags(versionState, release)}
5719
7500
  ${renderStudioReleaseStepValidate(versionState, release)}
7501
+ ${renderStudioReleaseStepIgnored()}
5720
7502
  ${renderStudioReleaseStepPublish(versionState, release)}
5721
7503
  </ol>
5722
7504
  </div>
@@ -5739,6 +7521,16 @@ function renderStudioReleaseStepShell(number, title, summary, body) {
5739
7521
  `;
5740
7522
  }
5741
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
+
5742
7534
  function renderStudioReleaseStepVersion(versionState, release) {
5743
7535
  const localVersion = versionState?.localVersion ?? "—";
5744
7536
  const stable = versionState?.readmeStableTag ?? "—";
@@ -5793,7 +7585,11 @@ function renderStudioReleaseStepVersion(versionState, release) {
5793
7585
  return renderStudioReleaseStepShell(1, "Version state", summary, body);
5794
7586
  }
5795
7587
 
5796
- function renderStudioReleaseStepTags(release) {
7588
+ function releaseTagDraftValue(versionState, release) {
7589
+ return release.newTagDraft || versionState?.localVersion || "";
7590
+ }
7591
+
7592
+ function renderStudioReleaseStepTags(versionState, release) {
5797
7593
  let body;
5798
7594
  if (release.tagsLoading && !release.tags) {
5799
7595
  body = loadingShell("Reading SVN tags…");
@@ -5814,22 +7610,31 @@ function renderStudioReleaseStepTags(release) {
5814
7610
  const tagRows = (list.tags ?? [])
5815
7611
  .map((tag) => renderStudioReleaseTagRow(tag))
5816
7612
  .join("");
7613
+ const newTagValue = releaseTagDraftValue(versionState, release);
7614
+ const currentVersionTag = versionState?.localVersion
7615
+ ? (list.tags ?? []).find((tag) => tag.name === versionState.localVersion)
7616
+ : null;
7617
+ const newTagControl = currentVersionTag
7618
+ ? currentVersionTag.isUncommitted
7619
+ ? `<p class="ps-release-tag-ready"><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>${escapeHtml(`Tag ${versionState.localVersion} is ready. Run a dry-run release next.`)}</p>`
7620
+ : `<p class="ps-release-tag-ready is-blocked"><span class="dashicons dashicons-warning" aria-hidden="true"></span>${escapeHtml(`Tag ${versionState.localVersion} already exists on WordPress.org SVN. Bump the version before creating a new tag.`)}</p>`
7621
+ : `<div class="ps-release-step-newtag">
7622
+ <label>
7623
+ <span>Create tag from current version</span>
7624
+ <input type="text" id="studio-release-new-tag" value="${escapeAttr(newTagValue)}" placeholder="1.2.3" />
7625
+ </label>
7626
+ <button class="button button-secondary" type="button" data-action="studio-release-create">
7627
+ <span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>
7628
+ Create tag
7629
+ </button>
7630
+ </div>`;
5817
7631
  body = `
5818
7632
  <ul class="ps-release-tag-list">
5819
7633
  ${trunkRow}
5820
7634
  ${tagRows || `<li class="ps-release-tag-empty">No tags yet.</li>`}
5821
7635
  </ul>
5822
7636
  ${renderStudioReleaseSwitchConflict(release)}
5823
- <div class="ps-release-step-newtag">
5824
- <label>
5825
- <span>Create tag from current version</span>
5826
- <input type="text" id="studio-release-new-tag" value="${escapeAttr(release.newTagDraft ?? "")}" placeholder="1.2.3" />
5827
- </label>
5828
- <button class="button button-secondary" type="button" data-action="studio-release-create">
5829
- <span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>
5830
- Create tag
5831
- </button>
5832
- </div>
7637
+ ${newTagControl}
5833
7638
  ${release.newTagError ? `<p class="ps-release-inline-error"><span class="dashicons dashicons-warning" aria-hidden="true"></span>${escapeHtml(release.newTagError)}</p>` : ""}
5834
7639
  `;
5835
7640
  }
@@ -5881,9 +7686,11 @@ function renderStudioReleaseTagRow(tag) {
5881
7686
  const switchingLabel = release.switchingResolution === "override" || release.switchingResolution === "revert"
5882
7687
  ? "Resolving"
5883
7688
  : "Switching";
5884
- const switchButton = tag.isCurrent
5885
- ? ""
5886
- : `<button class="button button-small" type="button" data-action="studio-release-switch" data-tag="${escapeAttr(tag.name)}" ${anySwitching ? "disabled aria-disabled=\"true\"" : ""}>${switching ? `<span class="dashicons dashicons-update" aria-hidden="true"></span>${switchingLabel}` : "Switch"}</button>`;
7689
+ const switchButton = tag.isUncommitted
7690
+ ? `<span class="ps-release-tag-local-note" title="This tag exists locally and will be published by the release step.">Ready for release</span>`
7691
+ : tag.isCurrent
7692
+ ? ""
7693
+ : `<button class="button button-small" type="button" data-action="studio-release-switch" data-tag="${escapeAttr(tag.name)}" ${anySwitching ? "disabled aria-disabled=\"true\"" : ""}>${switching ? `<span class="dashicons dashicons-update" aria-hidden="true"></span>${switchingLabel}` : "Switch"}</button>`;
5887
7694
  const confirmKey = `delete-tag:${tag.name}`;
5888
7695
  const pendingConfirm = state.studio.pendingConfirms?.get(confirmKey);
5889
7696
  const deleteButton = tag.isUncommitted
@@ -5939,6 +7746,183 @@ function renderStudioReleaseStepValidate(versionState, release) {
5939
7746
  return renderStudioReleaseStepShell(3, "Validate", summaryLine, body);
5940
7747
  }
5941
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
+
7815
+ function studioPublishRouteDetails(action) {
7816
+ switch (action) {
7817
+ case "submit":
7818
+ return {
7819
+ label: "Submit new plugin",
7820
+ shortLabel: "submit",
7821
+ icon: "dashicons-upload",
7822
+ description: "Use this for a first WordPress.org review. Pressship builds a package and uploads it to the plugin submission flow after confirmation.",
7823
+ dryRunLabel: "Dry-run submit",
7824
+ confirmLabel: "Confirm submit",
7825
+ resultLabel: "WordPress.org submission"
7826
+ };
7827
+ case "release":
7828
+ return {
7829
+ label: "Release update",
7830
+ shortLabel: "release",
7831
+ icon: "ps-icon-rocket",
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.",
7833
+ dryRunLabel: "Dry-run release",
7834
+ confirmLabel: "Confirm release",
7835
+ resultLabel: "SVN release"
7836
+ };
7837
+ default:
7838
+ return {
7839
+ label: "Auto decide",
7840
+ shortLabel: "auto",
7841
+ icon: "dashicons-controls-play",
7842
+ description: "Best default. Pressship checks WordPress.org and SVN, then chooses submit for first-time review or release for an existing plugin.",
7843
+ dryRunLabel: "Dry-run auto",
7844
+ confirmLabel: "Confirm publish",
7845
+ resultLabel: "publish route"
7846
+ };
7847
+ }
7848
+ }
7849
+
7850
+ function renderStudioReleasePublishOption(action, options = {}) {
7851
+ const details = studioPublishRouteDetails(action);
7852
+ const active = options.activeAction === action;
7853
+ const chosen = options.detectedRoute === action;
7854
+ const isDefault = options.defaultAction === action;
7855
+ const recommended = action === "auto";
7856
+ const disabled = options.running ? "disabled aria-disabled=\"true\"" : "";
7857
+ const badges = [
7858
+ recommended ? "Recommended" : "",
7859
+ isDefault ? "Default" : "",
7860
+ chosen ? "Selected" : ""
7861
+ ].filter(Boolean);
7862
+
7863
+ return `
7864
+ <button class="ps-release-publish-option${active ? " is-active" : ""}${chosen ? " is-selected" : ""}" type="button" data-action="dry-run-publish" data-id="${escapeAttr(state.studio.id)}" data-publish-action="${escapeAttr(action)}" ${disabled}>
7865
+ <span class="ps-release-option-icon dashicons ${escapeAttr(details.icon)}" aria-hidden="true"></span>
7866
+ <span class="ps-release-option-content">
7867
+ <span class="ps-release-option-title">
7868
+ <strong>${escapeHtml(details.label)}</strong>
7869
+ ${badges.map((badge) => `<em>${escapeHtml(badge)}</em>`).join("")}
7870
+ </span>
7871
+ <span class="ps-release-option-copy">${escapeHtml(details.description)}</span>
7872
+ </span>
7873
+ <span class="ps-release-option-action">${escapeHtml(details.dryRunLabel)}</span>
7874
+ </button>
7875
+ `;
7876
+ }
7877
+
7878
+ function renderStudioReleasePublishSummary(dryRun, pendingConfirm, running) {
7879
+ if (running) {
7880
+ return `
7881
+ <div class="ps-release-publish-summary is-running" role="status">
7882
+ <span class="dashicons dashicons-update" aria-hidden="true"></span>
7883
+ <p>
7884
+ <strong>Previewing publish route</strong>
7885
+ <small>Validating the package and checking which path is safe to confirm.</small>
7886
+ </p>
7887
+ </div>
7888
+ `;
7889
+ }
7890
+
7891
+ if (!dryRun) {
7892
+ return `
7893
+ <p class="ps-release-step-muted">
7894
+ Pick a dry-run path above. A dry-run validates the package and shows exactly what will happen; nothing is uploaded or committed until you confirm.
7895
+ </p>
7896
+ `;
7897
+ }
7898
+
7899
+ const routeAction = dryRun.route?.action ?? "publish";
7900
+ const details = studioPublishRouteDetails(routeAction);
7901
+ const packageSummary = dryRun.package?.fileCount
7902
+ ? `${dryRun.package.fileCount} packaged files`
7903
+ : dryRun.package?.topLevelFolder
7904
+ ? `Package folder: ${dryRun.package.topLevelFolder}`
7905
+ : "";
7906
+
7907
+ return `
7908
+ <div class="ps-release-publish-summary">
7909
+ <div class="ps-release-publish-result">
7910
+ <span class="dashicons ${escapeAttr(details.icon)}" aria-hidden="true"></span>
7911
+ <p>
7912
+ <strong>${escapeHtml(details.label)}</strong>
7913
+ <small>${escapeHtml(dryRun.route?.reason ?? `Ready to preview ${details.resultLabel}.`)}</small>
7914
+ ${packageSummary ? `<small>${escapeHtml(packageSummary)}</small>` : ""}
7915
+ </p>
7916
+ </div>
7917
+ ${dryRun.canConfirm && dryRun.approvalId
7918
+ ? `<button class="button button-primary ps-release-confirm-button${pendingConfirm ? " is-confirming" : ""}" type="button" data-action="studio-release-publish" data-approval-id="${escapeAttr(dryRun.approvalId)}" data-action-label="${escapeAttr(routeAction)}">
7919
+ ${pendingConfirm ? "Click again to confirm" : escapeHtml(details.confirmLabel)}
7920
+ </button>`
7921
+ : `<p class="ps-release-step-muted">Dry-run did not pass. Fix validation or version findings before publishing.</p>`}
7922
+ </div>
7923
+ `;
7924
+ }
7925
+
5942
7926
  function renderStudioReleaseStepPublish(versionState, release) {
5943
7927
  const defaultAction = state.settings?.defaultPublishAction ?? "auto";
5944
7928
  const dryRun = release.dryRun;
@@ -5946,44 +7930,28 @@ function renderStudioReleaseStepPublish(versionState, release) {
5946
7930
  const publishConfirmKey = "publish";
5947
7931
  const pendingConfirm = state.studio.pendingConfirms?.get(publishConfirmKey);
5948
7932
  const detectedRoute = dryRun?.route?.action;
7933
+ const activeAction = detectedRoute ?? defaultAction;
5949
7934
 
5950
7935
  const body = `
7936
+ <div class="ps-release-publish-guide">
7937
+ <strong>Dry-run first, confirm second.</strong>
7938
+ <span>Choose the route you want to preview. The dry-run is safe; the later confirm button performs the upload or SVN publish.</span>
7939
+ </div>
5951
7940
  <div class="ps-release-publish-options">
5952
- <button class="button${detectedRoute === "submit" ? " button-primary" : ""}" type="button" data-action="dry-run-publish" data-id="${escapeAttr(state.studio.id)}" data-publish-action="submit" ${running ? "disabled" : ""}>
5953
- <span class="dashicons dashicons-upload" aria-hidden="true"></span>
5954
- Dry-run submit
5955
- </button>
5956
- <button class="button${detectedRoute === "release" ? " button-primary" : ""}" type="button" data-action="dry-run-publish" data-id="${escapeAttr(state.studio.id)}" data-publish-action="release" ${running ? "disabled" : ""}>
5957
- <span class="dashicons dashicons-update" aria-hidden="true"></span>
5958
- Dry-run release
5959
- </button>
5960
- <button class="button${defaultAction === "auto" ? " button-primary" : ""}" type="button" data-action="dry-run-publish" data-id="${escapeAttr(state.studio.id)}" data-publish-action="auto" ${running ? "disabled" : ""}>
5961
- <span class="dashicons dashicons-controls-play" aria-hidden="true"></span>
5962
- Auto dry-run
5963
- </button>
7941
+ ${renderStudioReleasePublishOption("auto", { activeAction, defaultAction, detectedRoute, running })}
7942
+ ${renderStudioReleasePublishOption("submit", { activeAction, defaultAction, detectedRoute, running })}
7943
+ ${renderStudioReleasePublishOption("release", { activeAction, defaultAction, detectedRoute, running })}
5964
7944
  </div>
5965
- ${dryRun
5966
- ? `<div class="ps-release-publish-summary">
5967
- <p>
5968
- <strong>${escapeHtml(dryRun.route?.action ?? "publish")}</strong>
5969
- <small>${escapeHtml(dryRun.route?.reason ?? "")}</small>
5970
- </p>
5971
- ${dryRun.canConfirm && dryRun.approvalId
5972
- ? `<button class="button button-primary ps-release-confirm-button${pendingConfirm ? " is-confirming" : ""}" type="button" data-action="studio-release-publish" data-approval-id="${escapeAttr(dryRun.approvalId)}" data-action-label="${escapeAttr(dryRun.route?.action ?? "publish")}">
5973
- ${pendingConfirm ? "Click again to confirm" : `Confirm ${escapeHtml(dryRun.route?.action ?? "publish")}`}
5974
- </button>`
5975
- : `<p class="ps-release-step-muted">Dry-run did not pass — fix validation findings before publishing.</p>`}
5976
- </div>`
5977
- : `<p class="ps-release-step-muted">Run a dry-run to preview the publish plan.</p>`}
7945
+ ${renderStudioReleasePublishSummary(dryRun, pendingConfirm, running)}
5978
7946
  `;
5979
7947
 
5980
7948
  const summary = dryRun
5981
- ? `Detected route: ${escapeHtml(dryRun.route?.action ?? "publish")}`
7949
+ ? `Detected route: ${escapeHtml(studioPublishRouteDetails(dryRun.route?.action).shortLabel)}`
5982
7950
  : versionState?.releaseBlocked
5983
7951
  ? "Blocked — fix version state before publishing"
5984
7952
  : "";
5985
7953
 
5986
- return renderStudioReleaseStepShell(4, "Submit / Release", summary, body);
7954
+ return renderStudioReleaseStepShell(5, "Submit / Release", summary, body);
5987
7955
  }
5988
7956
 
5989
7957
  /* ===================================================================
@@ -6020,16 +7988,19 @@ async function refreshStudioAfterReleaseSwitch(result = {}) {
6020
7988
  const localId = state.studio.id;
6021
7989
  const selectedPath = state.studio.selectedFile?.path;
6022
7990
  try {
6023
- const [detail, filesResult, checkState, versionState] = await Promise.all([
7991
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
6024
7992
  api(`/api/plugins/local/${encodeURIComponent(localId)}`),
6025
7993
  api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
6026
7994
  api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
6027
- 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())
6028
7997
  ]);
6029
7998
 
6030
7999
  applyStudioPluginDetail("local", localId, detail);
6031
8000
  state.studio.files = filesResult.files ?? [];
8001
+ state.studio.directories = filesResult.directories ?? [];
6032
8002
  applyStudioCheckState(checkState.state);
8003
+ applyStudioIgnoreState(ignoreState);
6033
8004
  if (versionState) {
6034
8005
  state.versionStates.set(localId, versionState);
6035
8006
  renderLocal();
@@ -6060,10 +8031,54 @@ async function refreshStudioAfterReleaseSwitch(result = {}) {
6060
8031
  }
6061
8032
  }
6062
8033
 
8034
+ async function refreshStudioAfterVersionChange(localId) {
8035
+ if (!localId || state.studio.id !== localId || state.studio.scope !== "local") {
8036
+ return;
8037
+ }
8038
+
8039
+ const selectedPath = state.studio.selectedFile?.path;
8040
+ try {
8041
+ const [detail, filesResult, checkState, versionState, ignoreState] = await Promise.all([
8042
+ api(`/api/plugins/local/${encodeURIComponent(localId)}`),
8043
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/files`),
8044
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/check-state`).catch(() => ({ state: null })),
8045
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/version-state`).catch(() => null),
8046
+ api(`/api/plugins/local/${encodeURIComponent(localId)}/ignore-state`).catch(() => createInitialStudioIgnoreState())
8047
+ ]);
8048
+
8049
+ applyStudioPluginDetail("local", localId, detail);
8050
+ state.studio.files = filesResult.files ?? [];
8051
+ state.studio.directories = filesResult.directories ?? [];
8052
+ applyStudioCheckState(checkState.state);
8053
+ applyStudioIgnoreState(ignoreState);
8054
+ if (versionState) {
8055
+ state.versionStates.set(localId, versionState);
8056
+ if (!state.studio.release.newTagDraft || state.studio.release.newTagDraft === versionState.latestSvnTag) {
8057
+ state.studio.release.newTagDraft = versionState.localVersion ?? "";
8058
+ }
8059
+ }
8060
+
8061
+ const selectedStillExists = selectedPath && state.studio.files.some((file) => file.path === selectedPath);
8062
+ if (selectedStillExists) {
8063
+ await selectStudioFile(selectedPath, { force: true });
8064
+ } else {
8065
+ renderStudio();
8066
+ remountStudioEditorIfNeeded();
8067
+ }
8068
+ updateStudioSidebar();
8069
+ updateStudioControls();
8070
+ } catch (error) {
8071
+ appendStudioTerminal(`Studio reload after version change failed: ${error.message}`, "error");
8072
+ renderStudio();
8073
+ updateStudioSidebar();
8074
+ }
8075
+ }
8076
+
6063
8077
  async function createStudioReleaseTag() {
6064
8078
  if (!state.studio.id) return;
6065
8079
  const input = document.getElementById("studio-release-new-tag");
6066
- const name = (input?.value ?? state.studio.release.newTagDraft ?? "").trim();
8080
+ const versionState = state.versionStates.get(state.studio.id);
8081
+ const name = (input?.value ?? releaseTagDraftValue(versionState, state.studio.release)).trim();
6067
8082
  if (!name) {
6068
8083
  state.studio.release.newTagError = "Enter a tag name first.";
6069
8084
  updateStudioSidebar();
@@ -6134,6 +8149,7 @@ async function switchStudioReleaseTag(tag, resolution) {
6134
8149
 
6135
8150
  async function bumpStudioReleaseVersion(localId, bump) {
6136
8151
  if (!localId || !["patch", "minor", "major"].includes(bump)) return;
8152
+ appendStudioCliCommand(studioCliCommand(["version", bump, localPluginCliTarget(localId)]));
6137
8153
  if (state.studio.id !== localId) {
6138
8154
  // Outside the funnel (e.g. invoked from a command palette). Run without
6139
8155
  // the inline busy state — the global notice is enough feedback.
@@ -6174,7 +8190,7 @@ async function bumpStudioReleaseVersion(localId, bump) {
6174
8190
  }, 1500);
6175
8191
  await loadLocal();
6176
8192
  if (state.studio.id === localId) {
6177
- await refreshStudioVersionState();
8193
+ await refreshStudioAfterVersionChange(localId);
6178
8194
  }
6179
8195
  } catch (error) {
6180
8196
  state.studio.release.bumpInFlight = null;
@@ -6218,7 +8234,7 @@ async function setStudioCustomReleaseVersion(localId) {
6218
8234
  }, 1500);
6219
8235
  await loadLocal();
6220
8236
  if (state.studio.id === localId) {
6221
- await refreshStudioVersionState();
8237
+ await refreshStudioAfterVersionChange(localId);
6222
8238
  }
6223
8239
  } catch (error) {
6224
8240
  state.studio.release.bumpInFlight = null;
@@ -6231,6 +8247,9 @@ function applyStudioVersionChangeResult(localId, result) {
6231
8247
  if (result && typeof result === "object") {
6232
8248
  const { checkState, ...versionState } = result;
6233
8249
  state.versionStates.set(localId, versionState);
8250
+ if (state.studio.id === localId && versionState.localVersion) {
8251
+ state.studio.release.newTagDraft = versionState.localVersion;
8252
+ }
6234
8253
  }
6235
8254
  if (state.studio.id === localId && result && Object.prototype.hasOwnProperty.call(result, "checkState")) {
6236
8255
  applyStudioCheckState(result.checkState);
@@ -6246,6 +8265,9 @@ async function refreshStudioVersionState() {
6246
8265
  `/api/plugins/local/${encodeURIComponent(state.studio.id)}/version-state`
6247
8266
  );
6248
8267
  state.versionStates.set(state.studio.id, versionState);
8268
+ if (!state.studio.release.newTagDraft && versionState.localVersion) {
8269
+ state.studio.release.newTagDraft = versionState.localVersion;
8270
+ }
6249
8271
  updateStudioSidebar();
6250
8272
  } catch (error) {
6251
8273
  // ignore — sidebar will keep stale data and notice was already shown