@hienlh/ppm 0.17.31 → 0.17.33

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-ByZApv3Y.js → ai-resource-editor-CZc98en9.js} +1 -1
  5. package/dist/web/assets/{audio-preview-izB0KGPQ.js → audio-preview-XkBaDV-d.js} +1 -1
  6. package/dist/web/assets/{chat-tab-CwWL5DbD.js → chat-tab-Cpxrbm1O.js} +6 -6
  7. package/dist/web/assets/{code-editor-Bx-Q27Nm.js → code-editor-DeqsPj8M.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-B60UimS8.js → conflict-editor-CqORnyRu.js} +1 -1
  9. package/dist/web/assets/{csv-preview-BZv2SuCz.js → csv-preview-i79R-2TA.js} +1 -1
  10. package/dist/web/assets/{database-viewer-B5J_4lvX.js → database-viewer-ClrtBc_A.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-DiytoNvX.js → diff-viewer-CWUjtM3T.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CrjmF16z.js → docx-preview-CFjtBdLf.js} +1 -1
  13. package/dist/web/assets/{extension-webview-BSiQUxFD.js → extension-webview-DwRdXtK4.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-Fb5EefXW.js → git-log-panel-DI_uaWVT.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-Cjve7mDb.js → glide-data-grid-Q2kMqNeo.js} +1 -1
  16. package/dist/web/assets/{group-chat-tab-DT0X7Cbz.js → group-chat-tab-2NJcUl_4.js} +1 -1
  17. package/dist/web/assets/{image-preview-BiVYVqa9.js → image-preview-QyeYNiJt.js} +1 -1
  18. package/dist/web/assets/{index-CuAijZGh.js → index-BI35sAVB.js} +4 -4
  19. package/dist/web/assets/{keybindings-store-sakq3gsF.js → keybindings-store-55cr_nFT.js} +1 -1
  20. package/dist/web/assets/{markdown-renderer-Cct9JHnx.js → markdown-renderer-ClDzjZ5T.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-BsKIIM83.js → markdown-renderer-DoofKFOC.js} +1 -1
  22. package/dist/web/assets/{notification-store-D4TsAnN3.js → notification-store-Bwzk7bz9.js} +1 -1
  23. package/dist/web/assets/panel-store-D-RxcUgH.js +1 -0
  24. package/dist/web/assets/{pdf-preview-B6G1QpE6.js → pdf-preview-Dd65pYcK.js} +1 -1
  25. package/dist/web/assets/{postgres-viewer-CdFu2Yj9.js → postgres-viewer-BAeRjowr.js} +1 -1
  26. package/dist/web/assets/{settings-tab-Dh3bD_dg.js → settings-tab-D_Vz5vjU.js} +1 -1
  27. package/dist/web/assets/{sql-query-editor-C8XXOY2R.js → sql-query-editor-RrxjEhFI.js} +1 -1
  28. package/dist/web/assets/{sqlite-viewer-B1eXvaLK.js → sqlite-viewer-DULpl_6h.js} +1 -1
  29. package/dist/web/assets/{system-monitor-tab-D2QcCXl1.js → system-monitor-tab-DHOIu_5Y.js} +1 -1
  30. package/dist/web/assets/{tab-store-ByORqglW.js → tab-store-CNxCwdXw.js} +1 -1
  31. package/dist/web/assets/{terminal-tab-D2-buBAQ.js → terminal-tab-B64jdELv.js} +1 -1
  32. package/dist/web/assets/{tool-cards-DDxShmxN.js → tool-cards-DT3R2C18.js} +2 -2
  33. package/dist/web/assets/{use-monaco-theme-DMlVAYKD.js → use-monaco-theme-Bf_8qog4.js} +1 -1
  34. package/dist/web/assets/{video-preview-Cg3A7vrt.js → video-preview-C-ZB6w-Z.js} +1 -1
  35. package/dist/web/index.html +3 -3
  36. package/dist/web/sw.js +1 -1
  37. package/package.json +1 -1
  38. package/src/server/routes/chat.ts +13 -4
  39. package/src/services/db.service.ts +36 -1
  40. package/src/services/session-branch.service.ts +43 -10
  41. package/src/services/slash-discovery/builtin-commands.ts +7 -3
  42. package/src/services/slash-discovery/index.ts +8 -1
  43. package/src/services/slash-discovery/sdk-commands.ts +126 -0
  44. package/src/services/slash-discovery/types.ts +5 -0
  45. package/src/shared/fuzzy-search.ts +37 -5
  46. package/src/web/components/chat/chat-tab.tsx +25 -5
  47. package/src/web/components/chat/slash-command-picker.tsx +2 -0
  48. package/src/web/components/layout/tab-bar.tsx +3 -3
  49. package/src/web/stores/dock-actions.ts +14 -4
  50. package/src/web/stores/panel-store.ts +8 -14
  51. package/dist/web/assets/panel-store-DsDZX8Mi.js +0 -1
@@ -0,0 +1,126 @@
1
+ import { invalidateCache } from "./cache.ts";
2
+ import type { SlashItem } from "./types.ts";
3
+
4
+ /**
5
+ * Claude's own built-ins (/context, /init, /usage, …) live inside the CLI binary,
6
+ * so the filesystem scan in skill-loader.ts can never see them. The only way to
7
+ * enumerate them is to ask a live SDK session, which costs a CLI spawn — hence a
8
+ * much longer TTL than the filesystem cache.
9
+ */
10
+ const TTL_MS = 30 * 60 * 1000;
11
+ const FETCH_TIMEOUT_MS = 20_000;
12
+
13
+ interface CacheEntry {
14
+ items: SlashItem[];
15
+ at: number;
16
+ }
17
+
18
+ const cache = new Map<string, CacheEntry>();
19
+ const inflight = new Map<string, Promise<SlashItem[]>>();
20
+
21
+ function isFresh(entry: CacheEntry | undefined): entry is CacheEntry {
22
+ return !!entry && Date.now() - entry.at < TTL_MS;
23
+ }
24
+
25
+ /**
26
+ * Keep only the SDK commands nothing else already provides. The SDK reports every
27
+ * command it knows — including the skills the filesystem scan just found — so
28
+ * dropping known names leaves exactly the ones that live inside the CLI binary.
29
+ */
30
+ export function selectSdkOnlyCommands(sdkItems: SlashItem[], knownNames: Iterable<string>): SlashItem[] {
31
+ const known = new Set(knownNames);
32
+ return sdkItems.filter((item) => !known.has(item.name));
33
+ }
34
+
35
+ /** Cached SDK commands, or [] when none were fetched yet. Never spawns. */
36
+ export function getSdkCommands(projectPath: string): SlashItem[] {
37
+ const entry = cache.get(projectPath);
38
+ return isFresh(entry) ? entry.items : [];
39
+ }
40
+
41
+ /** Drop cached SDK commands so the next ensure() re-spawns. */
42
+ export function invalidateSdkCommands(projectPath?: string): void {
43
+ if (projectPath) cache.delete(projectPath);
44
+ else cache.clear();
45
+ }
46
+
47
+ async function fetchFromSdk(projectPath: string): Promise<SlashItem[]> {
48
+ // Loaded lazily: the CLI (`ppm skills`) uses this module's sync path only and
49
+ // must not pull in the provider's config/database dependencies.
50
+ const [{ query }, provider] = await Promise.all([
51
+ import("@anthropic-ai/claude-agent-sdk"),
52
+ import("../../providers/claude-agent-sdk.ts"),
53
+ ]);
54
+
55
+ const cliPath = provider.resolveCliExecutablePath();
56
+
57
+ // supportedCommands() resolves during the init handshake, but the CLI keeps
58
+ // running until its input stream ends — so hold the stream open until we're done.
59
+ let closeInput!: () => void;
60
+ const inputClosed = new Promise<void>((r) => { closeInput = r; });
61
+ async function* idleInput(): AsyncGenerator<never, void, undefined> {
62
+ await inputClosed;
63
+ }
64
+
65
+ const q = query({
66
+ prompt: idleInput(),
67
+ options: {
68
+ cwd: projectPath,
69
+ ...(provider.needsNodeInterpreter(process.platform, cliPath) && { executable: "node" as const }),
70
+ ...(cliPath && { pathToClaudeCodeExecutable: cliPath }),
71
+ settingSources: ["user", "project"],
72
+ maxTurns: 1,
73
+ },
74
+ });
75
+
76
+ let timer: ReturnType<typeof setTimeout> | undefined;
77
+ try {
78
+ const commands = await Promise.race([
79
+ q.supportedCommands(),
80
+ new Promise<never>((_, reject) => {
81
+ timer = setTimeout(() => reject(new Error("supportedCommands timed out")), FETCH_TIMEOUT_MS);
82
+ }),
83
+ ]);
84
+ return commands.map((cmd) => ({
85
+ type: "builtin" as const,
86
+ name: cmd.name,
87
+ description: cmd.description ?? "",
88
+ argumentHint: cmd.argumentHint || undefined,
89
+ scope: "bundled" as const,
90
+ category: "session",
91
+ handler: "sdk" as const,
92
+ }));
93
+ } finally {
94
+ if (timer) clearTimeout(timer);
95
+ closeInput();
96
+ try { await q.interrupt?.(); } catch { /* already exiting */ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Fetch SDK commands, reusing the cache. Concurrent callers share one spawn.
102
+ * Failures are cached too — an unauthenticated CLI would otherwise re-spawn on
103
+ * every picker open; the picker's refresh button clears it.
104
+ */
105
+ export function ensureSdkCommands(projectPath: string): Promise<SlashItem[]> {
106
+ const entry = cache.get(projectPath);
107
+ if (isFresh(entry)) return Promise.resolve(entry.items);
108
+
109
+ const existing = inflight.get(projectPath);
110
+ if (existing) return existing;
111
+
112
+ const pending = fetchFromSdk(projectPath)
113
+ .catch((e: unknown) => {
114
+ console.warn(`[slash] SDK command discovery failed: ${(e as Error).message}`);
115
+ return [] as SlashItem[];
116
+ })
117
+ .then((items) => {
118
+ cache.set(projectPath, { items, at: Date.now() });
119
+ invalidateCache(projectPath); // merged item list is now stale
120
+ return items;
121
+ })
122
+ .finally(() => { inflight.delete(projectPath); });
123
+
124
+ inflight.set(projectPath, pending);
125
+ return pending;
126
+ }
@@ -16,6 +16,9 @@ export type SlashItemType = "skill" | "command" | "builtin" | "agent";
16
16
  export type SlashItemScope = "project" | "user" | "bundled";
17
17
  export type ItemOrigin = "skills" | "commands" | "agents";
18
18
 
19
+ /** Who runs a built-in: PPM server, the web client, or the Claude SDK */
20
+ export type SlashHandler = "ppm" | "sdk" | "client";
21
+
19
22
  export interface SlashItem {
20
23
  type: SlashItemType;
21
24
  /** Slash name, e.g. "review", "devops/deploy", "ck:research" */
@@ -30,6 +33,8 @@ export interface SlashItem {
30
33
  model?: string;
31
34
  /** Agent-only: allowed tools (parsed from comma-separated string or YAML list) */
32
35
  tools?: string[];
36
+ /** Built-in only: which layer executes the command */
37
+ handler?: SlashHandler;
33
38
  }
34
39
 
35
40
  export interface SkillRoot {
@@ -48,9 +48,22 @@ export function scoreFuzzy(query: string, candidate: string): FuzzyScore | null
48
48
  return null;
49
49
  }
50
50
 
51
+ /**
52
+ * Promotion a recently used item gets, measured in match tiers. One tier lets a
53
+ * recent "contains" hit edge out a stranger's "prefix" hit, while a two-tier gap
54
+ * (prefix vs fuzzy) still wins on relevance.
55
+ *
56
+ * Only granted when the query matched the item's NAME. Descriptions are prose and
57
+ * match almost any short query, so boosting those would float every recent item
58
+ * to the top of every search.
59
+ */
60
+ const RECENT_TIER_BOOST = 1;
61
+
51
62
  /**
52
63
  * Search items by query with fuzzy matching.
53
- * Recently used items get a rank boost (sorted earlier within same rank tier).
64
+ * Recently used items are promoted by one match tier and then ordered
65
+ * most-recent-first, so a filtered list keeps the recency ordering the
66
+ * unfiltered picker shows.
54
67
  * Returns ranked results (best match first), truncated to limit.
55
68
  */
56
69
  export function searchFuzzy<T extends FuzzySearchable>(
@@ -63,8 +76,10 @@ export function searchFuzzy<T extends FuzzySearchable>(
63
76
  // Cap query length to prevent quadratic blowup in Levenshtein
64
77
  query = query.slice(0, 50);
65
78
 
66
- const recentSet = new Set(recentNames);
67
- const scored: Array<{ item: T; rank: number; distance: number; recent: boolean }> = [];
79
+ // Position in recentNames, not a boolean: the caller's list is already ordered
80
+ // most-recent-first and that order has to survive into the results.
81
+ const recentRank = new Map(recentNames.map((name, i) => [name, i]));
82
+ const scored: Array<{ item: T; rank: number; distance: number; recency: number }> = [];
68
83
 
69
84
  for (const item of items) {
70
85
  const nameScore = scoreFuzzy(query, item.name);
@@ -73,13 +88,30 @@ export function searchFuzzy<T extends FuzzySearchable>(
73
88
  .filter((s): s is FuzzyScore => s !== null)
74
89
  .sort((a, b) => a.rank - b.rank || a.distance - b.distance)[0];
75
90
 
76
- if (best) scored.push({ item, rank: best.rank, distance: best.distance, recent: recentSet.has(item.name) });
91
+ if (best) {
92
+ const recency = recentRank.get(item.name);
93
+ // "Name reached the winning tier", not "name outscored the description":
94
+ // a description can happen to match at a smaller offset and would then
95
+ // silently disqualify a perfectly good name match from the boost.
96
+ const boosted = recency !== undefined && nameScore?.rank === best.rank;
97
+ scored.push({
98
+ item,
99
+ rank: best.rank - (boosted ? RECENT_TIER_BOOST : 0),
100
+ distance: best.distance,
101
+ // Sentinel, not Infinity: Infinity - Infinity is NaN and would silently
102
+ // neutralise the comparator for every pair of non-recent items.
103
+ recency: recency ?? recentNames.length,
104
+ });
105
+ }
77
106
  }
78
107
 
108
+ // Recency outranks distance so it actually takes effect: for "contains" matches
109
+ // distance is the match offset, which differs per item and previously decided
110
+ // every comparison before recency was ever consulted.
79
111
  scored.sort((a, b) =>
80
112
  a.rank - b.rank
113
+ || a.recency - b.recency
81
114
  || a.distance - b.distance
82
- || (a.recent === b.recent ? 0 : a.recent ? -1 : 1)
83
115
  || a.item.name.localeCompare(b.item.name),
84
116
  );
85
117
 
@@ -227,11 +227,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
227
227
  return () => clearTimeout(t);
228
228
  }, [editForking]);
229
229
 
230
- const handleNewSession = useCallback(() => {
230
+ const handleNewSession = useCallback((title?: string, clearedFrom?: string) => {
231
231
  useTabStore.getState().openTab({
232
232
  type: "chat",
233
- title: "AI Chat",
234
- metadata: { projectName, providerId },
233
+ title: title || "AI Chat",
234
+ metadata: { projectName, providerId, ...(clearedFrom && { clearedFrom }) },
235
235
  projectId: projectName || null,
236
236
  closable: true,
237
237
  });
@@ -379,6 +379,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
379
379
  const session = await api.post<Session>(`${projectUrl(pName)}/chat/sessions`, {
380
380
  providerId,
381
381
  title: content.slice(0, 50),
382
+ // Set by /clear — the session only exists now, so persist the lineage here.
383
+ clearedFrom: metadata?.clearedFrom as string | undefined,
382
384
  });
383
385
  setSessionId(session.id);
384
386
  setProviderId(session.providerId);
@@ -392,12 +394,30 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
392
394
  }
393
395
  sendMessage(fullContent, { permissionMode, priority });
394
396
  },
395
- [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
397
+ [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata],
396
398
  );
397
399
 
400
+ // Read through a ref so handleInputSend keeps a stable identity — it is passed to
401
+ // the memoized MessageInput, and slashItems changes once the picker list loads.
402
+ const slashItemsRef = useRef(slashItems);
403
+ slashItemsRef.current = slashItems;
404
+
398
405
  /** Stable wrapper for MessageInput onSend — clears forkDraft + draft and delegates to handleSend */
399
406
  const handleInputSend = useCallback(
400
407
  (content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
408
+ // Client-handled built-ins act on the UI, so they must not reach the SDK.
409
+ const slash = content.trim().match(/^\/(\S+)(?:\s+([\s\S]+))?$/);
410
+ if (slash) {
411
+ const item = slashItemsRef.current.find(
412
+ (i) => i.handler === "client" && (i.name === slash[1] || i.aliases?.includes(slash[1]!)),
413
+ );
414
+ if (item?.name === "clear") {
415
+ clearDraft();
416
+ handleNewSession(slash[2]?.trim(), sessionId ?? undefined);
417
+ return;
418
+ }
419
+ }
420
+
401
421
  setForkDraft(undefined);
402
422
  clearDraft();
403
423
  if (editFork && sessionId && projectName) {
@@ -408,7 +428,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
408
428
  }
409
429
  handleSend(content, attachments, priority);
410
430
  },
411
- [handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments],
431
+ [handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
412
432
  );
413
433
 
414
434
  // Past user messages for the composer's ArrowUp/Down recall. Read through a ref
@@ -13,6 +13,8 @@ export interface SlashItem {
13
13
  aliases?: string[];
14
14
  /** Agent-only: model the subagent runs on */
15
15
  model?: string;
16
+ /** Built-in only: which layer executes the command */
17
+ handler?: "ppm" | "sdk" | "client";
16
18
  }
17
19
 
18
20
  interface SlashCommandPickerProps {
@@ -225,7 +225,8 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
225
225
 
226
226
  /**
227
227
  * Terminal-only "Move to Dock" / "Move to Editor" item.
228
- * The action moves the same live session; closing later re-docks it.
228
+ * Reparents the same live session the only way to keep a terminal running
229
+ * outside its current slot, since closing the tab ends it.
229
230
  */
230
231
  function dockMoveMenuItems(tab: Tab): React.ReactNode {
231
232
  if (tab.type !== "terminal") return null;
@@ -253,8 +254,7 @@ export const TabBar = memo(function TabBar({ panelId }: TabBarProps) {
253
254
  break;
254
255
  case "move-to-dock":
255
256
  // Park the terminal in the dock (same live session — reparented, no restart).
256
- panelState.moveTab(tab.id, effectivePanelId, DOCK_PANEL_ID);
257
- panelState.setDockVisible(true);
257
+ panelState.redockTab(tab.id, effectivePanelId);
258
258
  break;
259
259
  case "move-to-editor": {
260
260
  // Send a dock terminal back to a grid panel: prefer the focused grid
@@ -174,10 +174,8 @@ export function makeOpenInDock(set: Set, get: Get) {
174
174
  // ---------------------------------------------------------------------------
175
175
  // Re-dock: move a terminal tab from a grid panel back to __dock__ + show dock.
176
176
  //
177
- // Called by closeTab when a terminal is closed from a GRID panel.
178
- // Closing a terminal from the grid PARKS it in the dock instead of killing it —
179
- // real kill only happens when closed from WITHIN the dock, on shell exit, or
180
- // after the idle/grace period expires.
177
+ // Explicit user action only ("Move to Dock"): it parks a live terminal without
178
+ // restarting its PTY. Closing a tab never routes here close ends the session.
181
179
  //
182
180
  // IMPORTANT: this function MUST NOT call closeTab (loop guard). It uses the
183
181
  // low-level moveTab primitive directly via the store getter.
@@ -245,3 +243,15 @@ export function restoreDockForProject(
245
243
  ): DockState {
246
244
  return projectDock[projectName] ?? { visible: false, height: 30 };
247
245
  }
246
+
247
+ /**
248
+ * Collapse a persisted "visible" dock on mobile.
249
+ *
250
+ * Mobile renders the dock as a full-width bottom sheet covering the tab bar, so
251
+ * restoring a visible dock — persisted locally or synced from a desktop session —
252
+ * would pop the sheet open over the workspace before the user asks for it. The
253
+ * sheet only opens from the nav's terminal button.
254
+ */
255
+ export function collapseRestoredDockOnMobile(dock: DockState, isMobile: boolean): DockState {
256
+ return isMobile && dock.visible ? { ...dock, visible: false } : dock;
257
+ }
@@ -26,6 +26,7 @@ import {
26
26
  persistDock,
27
27
  snapshotDockForProject,
28
28
  restoreDockForProject,
29
+ collapseRestoredDockOnMobile,
29
30
  activeProjectDockTabCount,
30
31
  } from "./dock-actions";
31
32
 
@@ -284,7 +285,10 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
284
285
  const mergedPanels = { ...panels, ...migratedPanels, [DOCK_PANEL_ID]: mergedDockPanel };
285
286
  newProjectGrids[projectName] = loaded.grid;
286
287
  newProjectFocused[projectName] = loaded.focusedPanelId;
287
- const restoredDock = loaded.dock ?? restoreDockForProject(projectName, newProjectDock);
288
+ const restoredDock = collapseRestoredDockOnMobile(
289
+ loaded.dock ?? restoreDockForProject(projectName, newProjectDock),
290
+ get().isMobile(),
291
+ );
288
292
  set({
289
293
  currentProject: projectName,
290
294
  panels: mergedPanels,
@@ -518,19 +522,9 @@ export const usePanelStore = create<PanelStore>()((set, get) => {
518
522
  if (!panel) return;
519
523
  const pid = panel.id;
520
524
 
521
- // Location-based re-dock: closing a terminal from ANY grid panel parks it in the
522
- // dock instead of killing it. Real kill only when closed from WITHIN the dock,
523
- // on shell exit (onExit handler), or after idle/grace expiry.
524
- // Decision is purely location-based — no Tab.home flag needed.
525
- if (tabId.startsWith("terminal:") && pid !== DOCK_PANEL_ID) {
526
- // Park in dock — redockTab moves the tab object and shows the dock.
527
- // Does NOT strip the localStorage session key (PTY stays alive).
528
- // redockTab calls moveTab, never closeTab → no recursion.
529
- get().redockTab(tabId, pid);
530
- return;
531
- }
532
-
533
- // Real-close path: terminal closed from dock, or non-terminal tab.
525
+ // Closing a terminal ends it, from a grid panel and from the dock alike.
526
+ // Parking a live session is an explicit action ("Move to Dock" / redockTab),
527
+ // never a side effect of close.
534
528
  // Clear persisted terminal session so reopening creates a fresh PTY.
535
529
  if (tabId.startsWith("terminal:")) {
536
530
  try { localStorage.removeItem(`ppm:terminal-session:${tabId}`); } catch { /* */ }
@@ -1 +0,0 @@
1
- import{t as e}from"./react-8hrD3-kv.js";import{r as t}from"./utils-E0yyGxXt.js";var n=`__dock__`,r=new Set([`terminal`,`system-monitor`]);function i(){return{id:n,tabs:[],activeTabId:null,tabHistory:[]}}function a(){return`panel-${t()}`}function o(e=[],t=null){return{id:a(),tabs:e,activeTabId:t,tabHistory:t?[t]:[]}}function s(e){return e?1:3}function c(e,t){return e.map(e=>e.filter(e=>e!==t)).filter(e=>e.length>0)}function l(e,t){for(let n=0;n<e.length;n++){let r=e[n].indexOf(t);if(r!==-1)return{row:n,col:r}}return null}function u(e){let t=0;for(let n of Object.values(e))for(let e of n.tabs){let n=e.id.match(/^editor:untitled-(\d+)$/);n&&(t=Math.max(t,Number(n[1])))}return t+1}function d(e,n){switch(e){case`editor`:return n?.viewerKey?`editor:viewer:${n.viewerKey}`:n?.isUntitled?`editor:untitled-${n.untitledNumber??1}`:`editor:${n?.filePath??`untitled`}`;case`chat`:return`chat:${n?.providerId??`default`}/${n?.sessionId??t()}`;case`terminal`:return`terminal:${n?.terminalIndex??1}`;case`database`:return`database:${n?.connectionId??`default`}:${n?.tableName??``}`;case`sqlite`:return`sqlite:${n?.filePath??`default`}`;case`postgres`:return`postgres:${n?.connectionId??`default`}:${n?.tableName??``}`;case`extension`:return`extension:${String(n?.viewType??`unknown`).replace(/\.view$/,``)}`;case`git-diff`:return`git-diff:${n?.filePath??`unknown`}`;case`conflict-editor`:return`conflict-editor:${n?.filePath??`unknown`}`;case`settings`:return`settings`;case`group`:return`group:${n?.groupId??`unknown`}`;default:return`${e}:${t()}`}}var f=new Set([`ports`,`tunnels`]);function p(e){if(!e.tabs.some(e=>f.has(e.type)))return e;let t=new Set(e.tabs.filter(e=>f.has(e.type)).map(e=>e.id)),n=e.tabs.filter(e=>!t.has(e.id)),r=e.tabHistory.filter(e=>!t.has(e)),i=e.activeTabId&&!t.has(e.activeTabId)?e.activeTabId:r[r.length-1]??n[n.length-1]?.id??null;return{...e,tabs:n,tabHistory:r,activeTabId:i}}function m(e){let t={};for(let[n,r]of Object.entries(e.panels))t[n]=p(r);let n=e.dockPanel?p(e.dockPanel):e.dockPanel;return{...e,panels:t,dockPanel:n}}function h(e){let t={...e,panels:{...e.panels}};for(let[e,n]of Object.entries(t.panels)){let r=n.tabs.map(e=>{if(e.id.startsWith(`tab-`)){let t=d(e.type,e.metadata);return{...e,id:t}}return e}),i=new Map;n.tabs.forEach((e,t)=>{e.id!==r[t].id&&i.set(e.id,r[t].id)});let a=i.get(n.activeTabId??``)??n.activeTabId,o=n.tabHistory.map(e=>i.get(e)??e);t.panels[e]={...n,tabs:r,activeTabId:a,tabHistory:o}}return t}var g=`ppm-panels-`,_=`ppm-tabs-`;function v(e){return`${g}${e}`}function y(e,t){try{let n={...t,updatedAt:new Date().toISOString()};localStorage.setItem(v(e),JSON.stringify(n)),e!==`__global__`&&w(e,t)}catch{}}function b(e){try{let t=localStorage.getItem(v(e));if(t)return x(h(m(JSON.parse(t))))}catch{}let t=D(e);return t?x(m(t)):null}function x(e){let t=e.dock??{visible:!1,height:30},n;if(!e.dockPanel)n=i();else{let t=e.dockPanel.tabs.filter(e=>r.has(e.type)),i=new Set(t.map(e=>e.id)),a=e.dockPanel.tabHistory.filter(e=>i.has(e)),o=e.dockPanel.activeTabId&&i.has(e.dockPanel.activeTabId)?e.dockPanel.activeTabId:a[a.length-1]??t[t.length-1]?.id??null;n={...e.dockPanel,tabs:t,tabHistory:a,activeTabId:o}}return{...e,dock:t,dockPanel:n}}async function S(e){if(e===`__global__`)return null;try{let t={},n=localStorage.getItem(`ppm-auth-token`);n&&(t.Authorization=`Bearer ${n}`);let r=await fetch(`/api/project/${encodeURIComponent(e)}/workspace`,{headers:t});if(!r.ok)return null;let i=await r.json();return!i.ok||!i.data?null:{...i.data.layout,updatedAt:i.data.updatedAt}}catch{return null}}var C=new Map;function w(e,t){let n=C.get(e);n&&clearTimeout(n),C.set(e,setTimeout(async()=>{C.delete(e);try{let n={"Content-Type":`application/json`},r=localStorage.getItem(`ppm-auth-token`);r&&(n.Authorization=`Bearer ${r}`);let i=await fetch(`/api/project/${encodeURIComponent(e)}/workspace`,{method:`PUT`,headers:n,body:JSON.stringify({layout:t})});if(i.ok){let t=await i.json();if(t.data?.updatedAt){let n=`${g}${e}`,r=localStorage.getItem(n);if(r){let e=JSON.parse(r);e.updatedAt=t.data.updatedAt,localStorage.setItem(n,JSON.stringify(e))}}}}catch{}},1500))}function T(e,t){if(!e&&!t)return null;if(!e)return t;if(!t)return e;let n=new Date(e.updatedAt).getTime();return new Date(t.updatedAt).getTime()>=n?t:e}async function E(e){if(e===`__global__`)return!1;let t=await S(e);if(!t)return!1;let n=`${g}${e}`,r=null;try{let e=localStorage.getItem(n);r=e?JSON.parse(e):null}catch{}if(T(r,t)!==t)return!1;try{let e={...m(t),updatedAt:t.updatedAt};return localStorage.setItem(n,JSON.stringify(e)),!0}catch{return!1}}function D(e){try{let t=localStorage.getItem(`${_}${e}`);if(!t)return null;let n=JSON.parse(t);if(!n.tabs?.length)return null;let r=o(n.tabs,n.activeTabId),i={panels:{[r.id]:r},grid:[[r.id]],focusedPanelId:r.id};return y(e,i),localStorage.removeItem(`${_}${e}`),i}catch{return null}}var O=15,k=85;function A(e){return Math.min(k,Math.max(O,e))}function j(e,t){let n=e.filter(e=>e!==t);return n.push(t),n.length>50&&n.shift(),n}function M(e){let t=e().panels[n];if(!t)return 0;let r=e().currentProject;return t.tabs.filter(e=>!e.projectId||!r||e.projectId===r).length}function N(e){let t=e().currentProject;e().openInDock({type:`terminal`,title:`Terminal`,projectId:t??null,closable:!0,metadata:t?{projectName:t}:void 0})}function P(e,t){return function(){let{dock:n}=t(),r=!n.visible;e({dock:{...n,visible:r}}),z(t),r&&M(t)===0&&N(t)}}function F(e,t){return function(n){let{dock:r}=t();e({dock:{...r,visible:n}}),z(t)}}function I(e,t){return function(n){let{dock:r}=t();e({dock:{...r,height:A(n)}}),z(t)}}function L(e,t){return function(r){let{panels:a,dock:o}=t(),s=a.__dock__??i(),c=r;if(r.type===`terminal`&&!r.metadata?.terminalIndex){let e=Object.values(a).flatMap(e=>e.tabs).filter(e=>e.type===`terminal`).map(e=>{let t=e.id.match(/^terminal:(\d+)/);return t?parseInt(t[1],10):0}),t=e.length>0?Math.max(...e)+1:1;c={...r,metadata:{...r.metadata,terminalIndex:t}}}let l=d(c.type,c.metadata);if(s.tabs.find(e=>e.id===l))return e(e=>({dock:{...e.dock,visible:!0},panels:{...e.panels,[n]:{...s,activeTabId:l,tabHistory:j(s.tabHistory,l)}}})),z(t),l;let u={...c,id:l},f=[...s.tabs,u],p=j(s.tabHistory,l);return e(e=>({dock:{...e.dock,visible:!0},panels:{...e.panels,[n]:{...s,tabs:f,activeTabId:l,tabHistory:p}}})),z(t),l}}function R(e,t){return function(r,i){t().moveTab(r,i,n);let{dock:a}=t();e({dock:{...a,visible:!0}}),z(t)}}function z(e){let{currentProject:t,panels:n,grid:r,focusedPanelId:a,dock:o}=e();if(!t)return;let s=new Set(r.flat()),c={};for(let[e,t]of Object.entries(n))s.has(e)&&(c[e]=t);y(t,{panels:c,grid:r,focusedPanelId:a,dock:o,dockPanel:n.__dock__??i()})}function B(e,t,n){return{...n,[e]:t}}function V(e,t){return t[e]??{visible:!1,height:30}}var H=new Set([`settings`,`git-log`]),U=new Set([`projects`,`git-status`,`git-graph`]);function W(e,t){if(!e)return null;let n=e.tabs.filter(e=>!e.projectId||e.projectId===t);if(n.length===0)return null;for(let t=e.tabHistory.length-1;t>=0;t--){let r=e.tabHistory[t];if(n.some(e=>e.id===r))return r}return n[n.length-1].id}function G(e,t){let n=e.filter(e=>e!==t);return n.push(t),n.length>50&&n.shift(),n}function K(e,t){let n=Date.now();return e.map(e=>e.id===t?{...e,metadata:{...e.metadata,lastActiveAt:n}}:e)}function q(){let e=o(),t=i();return{panels:{[e.id]:e,[n]:t},grid:[[e.id]],focusedPanelId:e.id}}var J=e()((e,t)=>{function r(){let{currentProject:e,panels:r,grid:i,focusedPanelId:a,dock:o}=t();if(!e)return;let s=new Set(i.flat()),c={};for(let[e,t]of Object.entries(r))s.has(e)&&(c[e]=t);y(e,{panels:c,grid:i,focusedPanelId:a,dock:o,dockPanel:r[n]})}function a(e){return Object.values(t().panels).find(t=>t.tabs.some(t=>t.id===e))}function u(e){return e??t().focusedPanelId}return{...q(),currentProject:null,projectGrids:{},projectFocused:{},dock:{visible:!1,height:30},projectDock:{},dockExpanded:!1,toggleDock:P(e,t),setDockVisible:F(e,t),setDockHeight:I(e,t),toggleDockExpanded:()=>e(e=>({dockExpanded:!e.dockExpanded})),openInDock:L(e,t),redockTab:R(e,t),switchProject:r=>{let{currentProject:a,panels:s,grid:c,focusedPanelId:l,projectGrids:u,projectFocused:d,dock:f,projectDock:p}=t();if(a===r)return;let m={...u},h={...d},g=a?B(a,f,p):{...p};if(a){m[a]=c,h[a]=l;let e=new Set(c.flat()),t={};for(let[n,r]of Object.entries(s))e.has(n)&&(t[n]=r);y(a,{panels:t,grid:c,focusedPanelId:l,dock:f,dockPanel:s[n]})}if(m[r]){let t=m[r],i=h[r]??t[0]?.[0]??``,a=V(r,g),o=s[n];e({currentProject:r,panels:o?{...s,[n]:{...o,activeTabId:W(o,r)}}:s,grid:t,focusedPanelId:i,projectGrids:m,projectFocused:h,dock:a,projectDock:g});return}let _=b(r);if(_&&Object.keys(_.panels).length>0){let t={};for(let[e,n]of Object.entries(_.panels)){let r=n.tabs.filter(e=>!U.has(e.type)),i=n.tabHistory.filter(e=>r.some(t=>t.id===e)),a=n.activeTabId&&r.some(e=>e.id===n.activeTabId)?n.activeTabId:i[i.length-1]??r[0]?.id??null;t[e]={...n,tabs:r,tabHistory:i,activeTabId:a}}let a=s.__dock__??i(),o=_.dockPanel??i(),c=[...a.tabs];for(let e of o.tabs)c.some(t=>t.id===e.id)||c.push(e);let l={...a,tabs:c,tabHistory:[...new Set([...a.tabHistory,...o.tabHistory])].filter(e=>c.some(t=>t.id===e)),activeTabId:null};l.activeTabId=W(l,r);let u={...s,...t,[n]:l};m[r]=_.grid,h[r]=_.focusedPanelId;let d=_.dock??V(r,g);e({currentProject:r,panels:u,grid:_.grid,focusedPanelId:_.focusedPanelId,projectGrids:m,projectFocused:h,dock:d,projectDock:g})}else{let t=o(),i=[[t.id]],a={...s,[t.id]:t};m[r]=i,h[r]=t.id;let c=V(r,g);y(r,{panels:{[t.id]:t},grid:i,focusedPanelId:t.id,dock:c,dockPanel:s[n]}),e({currentProject:r,panels:a,grid:i,focusedPanelId:t.id,projectGrids:m,projectFocused:h,dock:c,projectDock:g})}},reloadProject:r=>{let{projectGrids:i,projectFocused:a,projectDock:o,panels:s}=t(),c={...i},l={...a},u={...o};delete c[r],delete l[r],delete u[r];let d=i[r],f=d?new Set(d.flat()):new Set,p={...s};for(let e of f)e!==`__dock__`&&delete p[e];let m=p[n];if(m){let e=m.tabs.filter(e=>e.projectId!==r),t=new Set(e.map(e=>e.id));p[n]={...m,tabs:e,tabHistory:m.tabHistory.filter(e=>t.has(e)),activeTabId:m.activeTabId&&t.has(m.activeTabId)?m.activeTabId:e[e.length-1]?.id??null}}e({projectGrids:c,projectFocused:l,projectDock:u,panels:p,currentProject:null}),t().switchProject(r)},setFocusedPanel:n=>{t().panels[n]&&e({focusedPanelId:n})},openTab:(n,i)=>{let a=t().isMobile(),o=a?t().grid[0]?.[0]??u(i):u(i);if(!t().panels[o])return``;if(n.type===`terminal`&&!n.metadata?.terminalIndex){let e=Object.values(t().panels).flatMap(e=>e.tabs).filter(e=>e.type===`terminal`).map(e=>{let t=e.id.match(/^terminal:(\d+)/);return t?parseInt(t[1],10):0}),r=e.length>0?Math.max(...e)+1:1;n={...n,metadata:{...n.metadata,terminalIndex:r}}}let s=d(n.type,n.metadata);if(n.type===`chat`&&n.metadata?.sessionId){let i=n.metadata.sessionId;for(let n of Object.values(t().panels)){let t=n.tabs.find(e=>e.type===`chat`&&e.metadata?.sessionId===i);if(t)return e(e=>({focusedPanelId:n.id,panels:{...e.panels,[n.id]:{...n,tabs:K(n.tabs,t.id),activeTabId:t.id,tabHistory:G(n.tabHistory,t.id)}}})),r(),t.id}}if(H.has(n.type))for(let n of Object.values(t().panels)){let t=n.tabs.find(e=>e.id===s);if(t)return e(e=>({focusedPanelId:n.id,panels:{...e.panels,[n.id]:{...n,tabs:K(n.tabs,t.id),activeTabId:t.id,tabHistory:G(n.tabHistory,t.id)}}})),r(),t.id}if(a)for(let n of t().grid.flat()){let i=t().panels[n];if(!i)continue;let a=i.tabs.find(e=>e.id===s||e.id.startsWith(`${s}@`));if(a)return e(e=>({focusedPanelId:i.id,panels:{...e.panels,[i.id]:{...i,tabs:K(i.tabs,a.id),activeTabId:a.id,tabHistory:G(i.tabHistory,a.id)}}})),r(),a.id}let c=t().panels[o],l=c.tabs.find(e=>e.id===s);if(l)return e(e=>({panels:{...e.panels,[o]:{...c,tabs:K(c.tabs,l.id),activeTabId:l.id,tabHistory:G(c.tabHistory,l.id)}}})),r(),l.id;let f=Object.values(t().panels).some(e=>e.id!==o&&e.tabs.some(e=>e.id===s))?`${s}@${o}`:s,p={...n,id:f,metadata:{...n.metadata,lastActiveAt:Date.now()}};return e(e=>{let t=e.panels[o];return{focusedPanelId:o,panels:{...e.panels,[o]:{...t,tabs:[...t.tabs,p],activeTabId:f,tabHistory:G(t.tabHistory,f)}}}}),r(),f},closeTab:(n,i)=>{let o=i?t().panels[i]:a(n);if(!o)return;let s=o.id;if(n.startsWith(`terminal:`)&&s!==`__dock__`){t().redockTab(n,s);return}if(n.startsWith(`terminal:`))try{localStorage.removeItem(`ppm:terminal-session:${n}`)}catch{}e(e=>{let t=e.panels[s],r=t.tabs.filter(e=>e.id!==n),i=t.tabHistory.filter(e=>e!==n),a=t.activeTabId;if(t.activeTabId===n){let e=i.length>0?i[i.length-1]:null;a=e&&r.some(t=>t.id===e)?e:r[r.length-1]?.id??null}let o=e.grid.flat().length;if(r.length===0&&o>1&&s!==`__dock__`){let{[s]:t,...n}=e.panels,r=c(e.grid,s);return{panels:n,grid:r,focusedPanelId:e.focusedPanelId===s?r.flat()[0]??Object.keys(n)[0]:e.focusedPanelId}}return{panels:{...e.panels,[s]:{...t,tabs:r,activeTabId:a,tabHistory:i}}}}),r(),s===`__dock__`&&M(t)===0&&t().setDockVisible(!1)},setActiveTab:(n,i)=>{let o=i?t().panels[i]:a(n);if(!o)return;let s=o.id;e(e=>{let t=e.panels[s];return{focusedPanelId:s,panels:{...e.panels,[s]:{...t,tabs:K(t.tabs,n),activeTabId:n,tabHistory:G(t.tabHistory,n)}}}}),r()},updateTab:(t,n)=>{let i=a(t);i&&(e(e=>({panels:{...e.panels,[i.id]:{...i,tabs:i.tabs.map(e=>e.id===t?{...e,...n}:e)}}})),r())},reorderTab:(n,i,a)=>{let o=t().panels[i];if(!o)return;let s=o.tabs.findIndex(e=>e.id===n);if(s===-1||s===a)return;let c=[...o.tabs],[l]=c.splice(s,1);c.splice(a,0,l),e(e=>({panels:{...e.panels,[i]:{...o,tabs:c}}})),r()},moveTab:(n,i,a,o)=>{if(i===a)return;let s=t().panels[i],l=t().panels[a];if(!s||!l)return;let u=s.tabs.find(e=>e.id===n);if(!u)return;let d=s.tabs.filter(e=>e.id!==n),f=s.tabHistory.filter(e=>e!==n),p=s.activeTabId===n?f[f.length-1]??d[d.length-1]?.id??null:s.activeTabId,m=[...l.tabs];o===void 0?m.push(u):m.splice(o,0,u),e(e=>{let t=e.grid.flat().length;if(d.length===0&&t>1&&i!==`__dock__`){let{[i]:t,...r}=e.panels;return{panels:{...r,[a]:{...l,tabs:m,activeTabId:n,tabHistory:G(l.tabHistory,n)}},grid:c(e.grid,i),focusedPanelId:a}}return{focusedPanelId:a,panels:{...e.panels,[i]:{...s,tabs:d,activeTabId:p,tabHistory:f},[a]:{...l,tabs:m,activeTabId:n,tabHistory:G(l.tabHistory,n)}}}}),r()},splitPanel:(n,i,a,u)=>{let{grid:d,panels:f}=t(),p=t().isMobile(),m=f[a];if(!m)return!1;let h=m.tabs.find(e=>e.id===i);if(!h)return!1;let g=l(d,u??a);if(!g)return!1;let _=n===`left`||n===`right`,v=n===`up`||n===`down`;if(_&&(d[g.row]?.length??0)>=s(p)||v&&d.length>=3)return!1;let y=o([h],h.id);y.tabHistory=[h.id];let b=m.tabs.filter(e=>e.id!==i),x=m.tabHistory.filter(e=>e!==i),S=m.activeTabId===i?x[x.length-1]??b[b.length-1]?.id??null:m.activeTabId,C;if(_)C=d.map((e,t)=>{if(t!==g.row)return e;let r=[...e],i=n===`right`?g.col+1:g.col;return r.splice(i,0,y.id),r});else{C=[...d];let e=n===`down`?g.row+1:g.row;C.splice(e,0,[y.id])}return e(e=>{let t=e.grid.flat().length,n={...e.panels,[y.id]:y};if(b.length===0&&t>1){let{[a]:e,...t}=n;n=t,C=c(C,a)}else n[a]={...m,tabs:b,activeTabId:S,tabHistory:x};return{panels:n,grid:C,focusedPanelId:y.id}}),r(),!0},closePanel:n=>{let{panels:i,grid:a}=t();if(a.flat().length<=1)return;let o=i[n];if(!o)return;l(a,n);let s=a.flat(),u=s.indexOf(n),d=u>0?s[u-1]:s[1],f=i[d];f&&(e(e=>{let{[n]:t,...r}=e.panels,i=[...f.tabs,...o.tabs],a=f.activeTabId??o.activeTabId;return{panels:{...r,[d]:{...f,tabs:i,activeTabId:a,tabHistory:[...f.tabHistory,...o.tabHistory]}},grid:c(e.grid,n),focusedPanelId:d}}),r())},getPanelForTab:e=>a(e),isMobile:()=>typeof window<`u`&&window.innerWidth<768}});export{u as a,l as i,n,E as o,o as r,s,J as t};