@hienlh/ppm 0.8.76 → 0.8.77

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 (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/web/assets/{browser-tab-D5GfU4Ja.js → browser-tab-DQJLMN11.js} +1 -1
  3. package/dist/web/assets/{chat-tab-BJeNwwUM.js → chat-tab-xp4ZqCGD.js} +4 -4
  4. package/dist/web/assets/{code-editor-CTjgdXh2.js → code-editor-De6Xs-Kq.js} +1 -1
  5. package/dist/web/assets/{database-viewer-QzEuetE6.js → database-viewer-CExMyEmq.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CvZ06EAH.js → diff-viewer-C7EECdwr.js} +1 -1
  7. package/dist/web/assets/git-graph-DqFYj10H.js +1 -0
  8. package/dist/web/assets/index-CiuAeXR3.js +37 -0
  9. package/dist/web/assets/keybindings-store-DYgvd7L0.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-BVxlq4zO.js → markdown-renderer-CnBEa3kk.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-DP0FOQOa.js → postgres-viewer-DvMnxJ7g.js} +1 -1
  12. package/dist/web/assets/{settings-tab-CcmhnYpw.js → settings-tab-C0ltpIWq.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-4a4hHLZk.js → sqlite-viewer-DR9KKOhW.js} +1 -1
  14. package/dist/web/assets/tab-store-BJw7OCmy.js +1 -0
  15. package/dist/web/assets/{terminal-tab-CKsBIgnq.js → terminal-tab-B__sTLzq.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-BwIb9BHq.js → use-monaco-theme-BZiSwNRE.js} +1 -1
  17. package/dist/web/index.html +2 -2
  18. package/dist/web/sw.js +1 -1
  19. package/docs/code-standards.md +126 -0
  20. package/docs/project-changelog.md +36 -1
  21. package/docs/system-architecture.md +35 -3
  22. package/package.json +1 -1
  23. package/src/server/routes/project-scoped.ts +2 -0
  24. package/src/server/routes/workspace.ts +35 -0
  25. package/src/services/db.service.ts +37 -1
  26. package/src/web/app.tsx +37 -35
  27. package/src/web/hooks/use-url-sync.ts +173 -21
  28. package/src/web/stores/panel-store.ts +63 -9
  29. package/src/web/stores/panel-utils.ts +145 -3
  30. package/dist/web/assets/git-graph-BQqdvSjX.js +0 -1
  31. package/dist/web/assets/index-5a-tMkk5.js +0 -37
  32. package/dist/web/assets/keybindings-store-zY8zbJ2c.js +0 -1
  33. package/dist/web/assets/tab-store-BCfMgMKM.js +0 -1
@@ -2,7 +2,42 @@
2
2
 
3
3
  All notable changes to PPM are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
- **Current Version:** v0.8.60
5
+ **Current Version:** v0.8.76
6
+
7
+ ---
8
+
9
+ ## [0.8.77] — 2026-04-01 (Planned)
10
+
11
+ ### Added
12
+ - **Deterministic Tab URLs + Backend Workspace Sync**
13
+ - Tab IDs now deterministic ({type}:{identifier}) instead of random (tab-xxxx)
14
+ - URL format changed: `/project/{name}/{tabType}/{identifier}` (e.g., `/project/ppm/editor/src/index.ts`)
15
+ - New `workspace_state` database table (schema v10) persists tab layout per project
16
+ - GET/PUT `/api/project/:name/workspace` endpoints for server-side persistence
17
+ - Frontend syncs workspace layout to server with 1.5s debounce
18
+ - Deep linking from URL auto-creates tabs if missing
19
+ - Latest-wins conflict resolution: server timestamp > client localStorage
20
+ - Tab ID patterns standardized: `editor:path`, `chat:provider/sessionId`, `terminal:index`, etc.
21
+ - Migration from random IDs to deterministic IDs on first load
22
+
23
+ ### Technical Details
24
+ - **Files Created:**
25
+ - `src/server/routes/workspace.ts` — Workspace GET/PUT endpoints
26
+ - `src/web/hooks/use-workspace-sync.ts` — Server sync orchestration with debounce
27
+ - **Files Modified:**
28
+ - `src/services/db.service.ts` — Added workspace_state table (schema v10), helper functions
29
+ - `src/web/stores/panel-utils.ts` — Deterministic tab ID derivation logic
30
+ - `src/web/hooks/use-url-sync.ts` — URL parsing/building with new format
31
+ - `src/web/stores/panel-store.ts` — Load workspace from server on project switch
32
+ - `src/web/stores/tab-store.ts` — Tab interface updated with metadata
33
+ - **Breaking Changes:** URLs changed; old `/project/{name}/tab/{id}` URLs redirected or ignored (fallback to project root)
34
+ - **Migration:** Client-side: old random tab IDs migrated to deterministic format on first load
35
+
36
+ ### Benefits
37
+ - **Shareable deep links** — URLs point to specific files/chats (e.g., `/project/ppm/editor/src/index.ts`)
38
+ - **Cross-device persistence** — Workspace layout saved on server, restored on any device
39
+ - **URL-driven navigation** — Paste URL to recreate workspace state
40
+ - **Conflict-free sync** — Latest timestamp wins; no manual merge dialogs
6
41
 
7
42
  ---
8
43
 
@@ -140,10 +140,24 @@ POST /api/db/connections/:id/query → Execute query (readonly ch
140
140
  PATCH /api/db/connections/:id/cell → Update cell value (single)
141
141
  GET /api/upgrade/status → Get current + available versions, install method
142
142
  POST /api/upgrade/apply → Install new version, trigger supervisor self-replace
143
+ GET /api/project/:name/workspace → Get saved workspace layout + metadata
144
+ PUT /api/project/:name/workspace → Save workspace layout (layout JSON)
143
145
  WS /ws/project/:name/chat/:sessionId → Chat streaming
144
146
  WS /ws/project/:name/terminal/:id → Terminal I/O
145
147
  ```
146
148
 
149
+ **URL Format (Deterministic Tabs, v0.8.77+):**
150
+ ```
151
+ /project/{name} → Project root (project switcher)
152
+ /project/{name}/editor/{filePath} → Open editor tab (e.g., src/index.ts)
153
+ /project/{name}/chat/{provider}/{sessionId} → Open chat tab
154
+ /project/{name}/terminal/{index} → Open terminal tab
155
+ /project/{name}/database/{connId}/{table} → Open database browser
156
+ /project/{name}/git-graph → Git history graph (singleton)
157
+ /project/{name}/settings → Settings panel (singleton)
158
+ ```
159
+ Tab IDs are deterministic: `{type}:{identifier}` (e.g., `editor:src/index.ts`, `chat:claude/abc123`). Deep links auto-create missing tabs.
160
+
147
161
  ---
148
162
 
149
163
  ### Service Layer (Business Logic)
@@ -161,7 +175,7 @@ WS /ws/project/:name/terminal/:id → Terminal I/O
161
175
  |---------|---------|-------------|
162
176
  | **ChatService** | Session management, message streaming | createSession, streamMessage, getHistory |
163
177
  | **ConfigService** | Config loading (YAML→SQLite migration) | load, save, getToken |
164
- | **DbService** | SQLite persistence (9 tables, WAL, connections/accounts CRUD) | getDb, openTestDb, getConnections, insertConnection, deleteConnection, getTableCache |
178
+ | **DbService** | SQLite persistence (10 tables, WAL, connections/accounts/workspace CRUD) | getDb, openTestDb, getWorkspace, setWorkspace, getConnections, insertConnection, deleteConnection, getTableCache |
165
179
  | **TableCacheService** | Cache table metadata, search tables | syncTables, searchTables, invalidateCache |
166
180
  | **GitService** | Git command execution | status, diff, commit, stage, branch |
167
181
  | **FileService** | File operations with validation | read, write, tree, delete, mkdir |
@@ -260,11 +274,11 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
260
274
  - Enforce security (no parent directory access)
261
275
 
262
276
  **Key Patterns:**
263
- - SQLite: WAL mode, foreign keys, lazy init, schema v1 with 6 tables
277
+ - SQLite: WAL mode, foreign keys, lazy init, schema v10 (10 tables: config, connections, accounts, usage_history, session_logs, push_subscriptions, session_map, table_metadata, session_logs, workspace_state)
264
278
  - Path validation: `projectPath/relativePath` only, reject `..`
265
279
  - Caching: Directory trees cached with TTL
266
280
  - Error handling: Descriptive messages (file not found, permission denied)
267
- - Migration: Automatic YAML→SQLite migration on first run with new db.service
281
+ - Migration: Automatic YAML→SQLite migration on first run with new db.service; schema auto-upgrade on version bump
268
282
 
269
283
  ---
270
284
 
@@ -284,6 +298,24 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
284
298
  const messages = chatStore((s) => s.messages); // Subscribe to messages only
285
299
  ```
286
300
 
301
+ #### Workspace Sync (v0.8.77+)
302
+
303
+ **Deterministic Tab IDs & URL Routing:**
304
+ - Tab IDs derived from type + metadata: `deriveTabId(type, metadata) → {type}:{identifier}`
305
+ - Examples: `editor:src/index.ts`, `chat:claude/abc123`, `terminal:1`, `git-graph`
306
+ - URLs rebuilt from active tab: `/project/{name}/{type}/{identifier}`
307
+ - Deep linking: URL → `parseUrlState()` → auto-create tabs if missing
308
+
309
+ **Workspace Persistence:**
310
+ 1. **Client**: PanelStore layout (grid, panels, tabs) cached in localStorage per project
311
+ 2. **Server**: Workspace JSON persisted in `workspace_state` SQLite table
312
+ 3. **Sync Flow:**
313
+ - User loads project → fetch workspace from server (GET `/api/project/:name/workspace`)
314
+ - Latest-wins: server `updated_at` vs client localStorage timestamp
315
+ - Panel layout changes debounced (1.5s) → POST to server
316
+ - On reconnect: server layout restored, client edits queued
317
+ 4. **Cross-Device:** Any device can load workspace, browser restores exact grid + active tabs
318
+
287
319
  ---
288
320
 
289
321
  ## Communication Protocols
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.76",
3
+ "version": "0.8.77",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import { chatRoutes } from "./chat.ts";
4
4
  import { gitRoutes } from "./git.ts";
5
5
  import { fileRoutes } from "./files.ts";
6
6
  import { sqliteRoutes } from "./sqlite.ts";
7
+ import { workspaceRoutes } from "./workspace.ts";
7
8
 
8
9
  type Env = { Variables: { projectPath: string; projectName: string } };
9
10
 
@@ -27,3 +28,4 @@ projectScopedRouter.route("/chat", chatRoutes);
27
28
  projectScopedRouter.route("/git", gitRoutes);
28
29
  projectScopedRouter.route("/files", fileRoutes);
29
30
  projectScopedRouter.route("/sqlite", sqliteRoutes);
31
+ projectScopedRouter.route("/workspace", workspaceRoutes);
@@ -0,0 +1,35 @@
1
+ import { Hono } from "hono";
2
+ import { getWorkspace, setWorkspace } from "../../services/db.service.ts";
3
+ import { ok, err } from "../../types/api.ts";
4
+
5
+ type Env = { Variables: { projectPath: string; projectName: string } };
6
+
7
+ export const workspaceRoutes = new Hono<Env>();
8
+
9
+ /** GET /workspace — load saved workspace layout */
10
+ workspaceRoutes.get("/", (c) => {
11
+ try {
12
+ const projectName = c.get("projectName");
13
+ const row = getWorkspace(projectName);
14
+ if (!row) return c.json(ok(null));
15
+ return c.json(ok({
16
+ layout: JSON.parse(row.layout_json),
17
+ updatedAt: row.updated_at,
18
+ }));
19
+ } catch (e) {
20
+ return c.json(err((e as Error).message), 500);
21
+ }
22
+ });
23
+
24
+ /** PUT /workspace — save workspace layout */
25
+ workspaceRoutes.put("/", async (c) => {
26
+ try {
27
+ const projectName = c.get("projectName");
28
+ const body = await c.req.json<{ layout: unknown }>();
29
+ if (!body.layout) return c.json(err("Missing layout"), 400);
30
+ const updatedAt = setWorkspace(projectName, JSON.stringify(body.layout));
31
+ return c.json(ok({ updatedAt }));
32
+ } catch (e) {
33
+ return c.json(err((e as Error).message), 500);
34
+ }
35
+ });
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 9;
7
+ const CURRENT_SCHEMA_VERSION = 10;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -251,6 +251,42 @@ function runMigrations(database: Database): void {
251
251
  PRAGMA user_version = 9;
252
252
  `);
253
253
  }
254
+
255
+ if (current < 10) {
256
+ database.exec(`
257
+ CREATE TABLE IF NOT EXISTS workspace_state (
258
+ project_name TEXT PRIMARY KEY,
259
+ layout_json TEXT NOT NULL,
260
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
261
+ );
262
+
263
+ PRAGMA user_version = 10;
264
+ `);
265
+ }
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Workspace helpers
270
+ // ---------------------------------------------------------------------------
271
+
272
+ export interface WorkspaceRow {
273
+ project_name: string;
274
+ layout_json: string;
275
+ updated_at: string;
276
+ }
277
+
278
+ export function getWorkspace(projectName: string): WorkspaceRow | null {
279
+ return getDb().query(
280
+ "SELECT project_name, layout_json, updated_at FROM workspace_state WHERE project_name = ?",
281
+ ).get(projectName) as WorkspaceRow | null;
282
+ }
283
+
284
+ export function setWorkspace(projectName: string, layoutJson: string): string {
285
+ const now = new Date().toISOString();
286
+ getDb().query(
287
+ "INSERT INTO workspace_state (project_name, layout_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(project_name) DO UPDATE SET layout_json = excluded.layout_json, updated_at = excluded.updated_at",
288
+ ).run(projectName, layoutJson, now);
289
+ return now;
254
290
  }
255
291
 
256
292
  // ---------------------------------------------------------------------------
package/src/web/app.tsx CHANGED
@@ -10,12 +10,18 @@ import { ProjectBottomSheet } from "@/components/layout/project-bottom-sheet";
10
10
  import { LoginScreen } from "@/components/auth/login-screen";
11
11
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
12
12
  import { useTabStore } from "@/stores/tab-store";
13
+ import { usePanelStore } from "@/stores/panel-store";
14
+ import {
15
+ fetchWorkspaceFromServer,
16
+ resolveWorkspaceConflict,
17
+ savePanelLayout,
18
+ } from "@/stores/panel-utils";
13
19
  import {
14
20
  useSettingsStore,
15
21
  applyThemeClass,
16
22
  } from "@/stores/settings-store";
17
23
  import { getAuthToken } from "@/lib/api-client";
18
- import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
24
+ import { useUrlSync, parseUrlState, autoOpenFromUrl } from "@/hooks/use-url-sync";
19
25
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
20
26
  import { useNotificationBadge } from "@/hooks/use-notification-badge";
21
27
  import { useServerReload } from "@/hooks/use-server-reload";
@@ -127,56 +133,52 @@ export function App() {
127
133
  });
128
134
  }, [authState]);
129
135
 
130
- // Fetch projects after auth, then restore from URL if applicable
136
+ // Fetch projects after auth, then restore workspace + URL
131
137
  useEffect(() => {
132
138
  if (authState !== "authenticated") return;
133
139
 
134
- fetchProjects().then(() => {
135
- const { projectName: urlProject, tabId: urlTab, openChat } = initialUrlRef.current;
140
+ fetchProjects().then(async () => {
141
+ const urlState = initialUrlRef.current;
136
142
  const { projects, customOrder } = useProjectStore.getState();
137
143
  if (projects.length === 0) return;
138
144
 
139
145
  // URL project takes priority, then fall back to first sorted project
140
- let target = urlProject ? projects.find((p) => p.name === urlProject) : undefined;
146
+ let target = urlState.projectName
147
+ ? projects.find((p) => p.name === urlState.projectName)
148
+ : undefined;
141
149
  if (!target) {
142
150
  target = resolveOrder(projects, customOrder)[0];
143
151
  }
144
- if (target) {
145
- useProjectStore.getState().setActiveProject(target);
146
- if (urlProject && urlTab) {
147
- queueMicrotask(() => {
148
- const { tabs } = useTabStore.getState();
149
- if (tabs.some((t) => t.id === urlTab)) {
150
- useTabStore.getState().setActiveTab(urlTab);
151
- }
152
- });
152
+ if (!target) return;
153
+
154
+ useProjectStore.getState().setActiveProject(target);
155
+
156
+ // Fetch server workspace + compare with localStorage (latest-wins)
157
+ const serverLayout = await fetchWorkspaceFromServer(target.name);
158
+ if (serverLayout) {
159
+ const localRaw = localStorage.getItem(`ppm-panels-${target.name}`);
160
+ const localLayout = localRaw ? JSON.parse(localRaw) : null;
161
+ const resolved = resolveWorkspaceConflict(localLayout, serverLayout);
162
+ if (resolved && resolved === serverLayout) {
163
+ // Server wins — overwrite localStorage and reload panels
164
+ savePanelLayout(target.name, resolved);
165
+ usePanelStore.getState().reloadProject(target.name);
153
166
  }
154
167
  }
155
168
 
156
- // Deep link: ?openChat=sessionId open/focus the chat tab
157
- if (openChat) {
158
- queueMicrotask(() => {
159
- const { tabs, setActiveTab, openTab } = useTabStore.getState();
160
- const existing = tabs.find(
161
- (t) => t.type === "chat" && t.metadata?.sessionId === openChat,
162
- );
163
- if (existing) {
164
- setActiveTab(existing.id);
165
- } else {
166
- openTab({
167
- type: "chat",
168
- title: "Chat",
169
- projectId: target?.name ?? null,
170
- closable: true,
171
- metadata: { sessionId: openChat },
172
- });
173
- }
174
- // Clean up query param
169
+ // Auto-open target tab from URL (type-based)
170
+ queueMicrotask(() => {
171
+ if (urlState.tabType) {
172
+ autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
173
+ }
174
+ // Legacy: ?openChat= query param
175
+ if (urlState.openChat) {
176
+ autoOpenFromUrl("chat", urlState.openChat, target!.name);
175
177
  const url = new URL(window.location.href);
176
178
  url.searchParams.delete("openChat");
177
179
  window.history.replaceState(null, "", url.pathname);
178
- });
179
- }
180
+ }
181
+ });
180
182
  });
181
183
  }, [authState, fetchProjects]);
182
184
 
@@ -1,37 +1,181 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { useTabStore } from "@/stores/tab-store";
2
+ import { useTabStore, type TabType } from "@/stores/tab-store";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // URL state types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface UrlState {
9
+ projectName: string | null;
10
+ tabType: TabType | null;
11
+ tabIdentifier: string | null;
12
+ openChat: string | null;
13
+ }
14
+
15
+ const VALID_TAB_TYPES: TabType[] = [
16
+ "terminal", "chat", "editor", "database", "sqlite",
17
+ "postgres", "git-graph", "git-diff", "settings", "browser",
18
+ ];
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Parse URL → state
22
+ // ---------------------------------------------------------------------------
3
23
 
4
24
  /**
5
- * Parse the current URL to extract project name and tab ID.
6
- * Expected format: /project/:projectName/tab/:tabId
25
+ * Parse the current URL to extract project name and tab info.
26
+ * Format: /project/{name}/{tabType}/{...identifier}
7
27
  */
8
- export function parseUrlState(): { projectName: string | null; tabId: string | null; openChat: string | null } {
28
+ export function parseUrlState(): UrlState {
9
29
  const path = window.location.pathname;
10
- const match = path.match(/^\/project\/([^/]+)(?:\/tab\/([^/]+))?/);
11
30
  const params = new URLSearchParams(window.location.search);
12
31
  const openChat = params.get("openChat");
13
- if (!match) return { projectName: null, tabId: null, openChat };
14
- return {
15
- projectName: match[1] ? decodeURIComponent(match[1]) : null,
16
- tabId: match[2] ? decodeURIComponent(match[2]) : null,
17
- openChat,
18
- };
32
+
33
+ const match = path.match(/^\/project\/([^/]+)(?:\/([^/]+)(\/.*)?)?/);
34
+ if (!match) return { projectName: null, tabType: null, tabIdentifier: null, openChat };
35
+
36
+ const projectName = decodeURIComponent(match[1]!);
37
+ const rawType = match[2] ?? null;
38
+ const rawIdentifier = match[3] ? match[3].slice(1) : null; // strip leading /
39
+
40
+ // Legacy fallback: /project/{name}/tab/{tabId}
41
+ if (rawType === "tab") {
42
+ return { projectName, tabType: null, tabIdentifier: null, openChat };
43
+ }
44
+
45
+ const tabType = VALID_TAB_TYPES.includes(rawType as TabType) ? (rawType as TabType) : null;
46
+
47
+ return { projectName, tabType, tabIdentifier: rawIdentifier, openChat };
19
48
  }
20
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // Build URL from state
52
+ // ---------------------------------------------------------------------------
53
+
21
54
  /**
22
- * Build URL path from project name and tab ID.
55
+ * Build URL path from project name and deterministic tab ID.
23
56
  */
24
- function buildUrl(projectName: string | null, tabId: string | null): string {
57
+ export function buildUrl(projectName: string | null, tabId: string | null): string {
25
58
  if (!projectName || projectName === "__global__") return "/";
59
+
26
60
  let url = `/project/${encodeURIComponent(projectName)}`;
27
- if (tabId) url += `/tab/${encodeURIComponent(tabId)}`;
61
+ if (!tabId) return url;
62
+
63
+ // Strip panel suffix (@panel-xxx) — not meaningful in URLs
64
+ const atIdx = tabId.indexOf("@");
65
+ const cleanId = atIdx !== -1 ? tabId.slice(0, atIdx) : tabId;
66
+
67
+ // tabId format: "type:identifier" or "type" (singletons)
68
+ const colonIdx = cleanId.indexOf(":");
69
+ if (colonIdx === -1) {
70
+ // Singleton: git-graph, settings
71
+ url += `/${cleanId}`;
72
+ } else {
73
+ const type = cleanId.slice(0, colonIdx);
74
+ const identifier = cleanId.slice(colonIdx + 1);
75
+ // Real slashes — no encoding for paths. Only encode special URL chars.
76
+ url += `/${type}/${identifier.replace(/[?#]/g, encodeURIComponent)}`;
77
+ }
28
78
  return url;
29
79
  }
30
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Tab ID reconstruction from URL
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Reconstruct deterministic tab ID from parsed URL */
86
+ export function tabIdFromUrl(tabType: TabType, tabIdentifier: string | null): string {
87
+ if (!tabIdentifier) return tabType; // singleton
88
+ return `${tabType}:${tabIdentifier}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Auto-open tab from URL
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function buildMetadataFromUrl(
96
+ type: TabType, identifier: string | null, projectName: string,
97
+ ): Record<string, unknown> | null {
98
+ switch (type) {
99
+ case "editor": return identifier ? { filePath: identifier, projectName } : null;
100
+ case "chat": {
101
+ if (!identifier) return null;
102
+ const slashIdx = identifier.indexOf("/");
103
+ if (slashIdx === -1) return { sessionId: identifier, projectName };
104
+ const providerId = identifier.slice(0, slashIdx);
105
+ const sessionId = identifier.slice(slashIdx + 1);
106
+ return sessionId ? { sessionId, providerId, projectName } : null;
107
+ }
108
+ case "terminal": return { terminalIndex: parseInt(identifier ?? "1", 10), projectName };
109
+ case "git-graph": return { projectName };
110
+ case "git-diff": return identifier ? { filePath: identifier, projectName } : null;
111
+ case "settings": return {};
112
+ case "database": {
113
+ const [connId, tableName] = (identifier ?? "").split(":");
114
+ return connId ? { connectionId: connId, tableName: tableName ?? "" } : null;
115
+ }
116
+ case "sqlite": return identifier ? { filePath: identifier, projectName } : null;
117
+ case "postgres": {
118
+ const [connId, tableName] = (identifier ?? "").split(":");
119
+ return connId ? { connectionId: connId, tableName: tableName ?? "" } : null;
120
+ }
121
+ case "browser": return identifier ? { url: identifier } : null;
122
+ default: return null;
123
+ }
124
+ }
125
+
126
+ function buildTitleFromUrl(type: TabType, identifier: string | null): string {
127
+ switch (type) {
128
+ case "editor": return identifier?.split("/").pop() ?? "File";
129
+ case "chat": return "Chat";
130
+ case "terminal": return `Terminal ${identifier ?? "1"}`;
131
+ case "git-graph": return "Git Graph";
132
+ case "git-diff": return identifier?.split("/").pop() ?? "Diff";
133
+ case "settings": return "Settings";
134
+ case "database": return identifier ?? "Database";
135
+ case "sqlite": return identifier?.split("/").pop() ?? "SQLite";
136
+ case "postgres": return identifier ?? "PostgreSQL";
137
+ case "browser": return "Browser";
138
+ default: return type;
139
+ }
140
+ }
141
+
142
+ /** Auto-open or focus a tab based on URL state */
143
+ export function autoOpenFromUrl(
144
+ tabType: TabType,
145
+ tabIdentifier: string | null,
146
+ projectName: string,
147
+ ): void {
148
+ const { tabs, setActiveTab, openTab } = useTabStore.getState();
149
+ const expectedId = tabIdFromUrl(tabType, tabIdentifier);
150
+
151
+ // Check if tab already exists
152
+ const existing = tabs.find((t) => t.id === expectedId);
153
+ if (existing) {
154
+ setActiveTab(existing.id);
155
+ return;
156
+ }
157
+
158
+ // Auto-create tab from URL
159
+ const metadata = buildMetadataFromUrl(tabType, tabIdentifier, projectName);
160
+ if (!metadata) return;
161
+
162
+ openTab({
163
+ type: tabType,
164
+ title: buildTitleFromUrl(tabType, tabIdentifier),
165
+ projectId: projectName,
166
+ closable: true,
167
+ metadata,
168
+ });
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Hook: sync URL ↔ tab state
173
+ // ---------------------------------------------------------------------------
174
+
31
175
  /**
32
176
  * Sync tab/project state with browser URL.
33
- * - On tab/project change → pushState (enables back/forward navigation)
34
- * - On popstate (back/forward) → restore tab from URL
177
+ * - On tab/project change → pushState with type-based URL
178
+ * - On popstate (back/forward) → restore/create tab from URL
35
179
  */
36
180
  export function useUrlSync() {
37
181
  const activeTabId = useTabStore((s) => s.activeTabId);
@@ -40,7 +184,6 @@ export function useUrlSync() {
40
184
 
41
185
  // Push URL when active tab or project changes
42
186
  useEffect(() => {
43
- // Skip push if this change was triggered by popstate (back/forward)
44
187
  if (isPopState.current) {
45
188
  isPopState.current = false;
46
189
  return;
@@ -55,11 +198,20 @@ export function useUrlSync() {
55
198
  // Listen for back/forward navigation
56
199
  useEffect(() => {
57
200
  function handlePopState() {
58
- const { tabId } = parseUrlState();
201
+ const { tabType, tabIdentifier } = parseUrlState();
202
+ if (!tabType) return;
203
+
204
+ isPopState.current = true;
59
205
  const { tabs, setActiveTab } = useTabStore.getState();
60
- if (tabId && tabs.some((t) => t.id === tabId)) {
61
- isPopState.current = true;
62
- setActiveTab(tabId);
206
+ const expectedId = tabIdFromUrl(tabType, tabIdentifier);
207
+ const existing = tabs.find((t) => t.id === expectedId);
208
+
209
+ if (existing) {
210
+ setActiveTab(existing.id);
211
+ } else {
212
+ // Auto-open tab on back/forward if it was closed
213
+ const project = useTabStore.getState().currentProject;
214
+ if (project) autoOpenFromUrl(tabType, tabIdentifier, project);
63
215
  }
64
216
  }
65
217