@hienlh/ppm 0.8.76 → 0.8.78
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/CHANGELOG.md +19 -0
- package/dist/web/assets/{browser-tab-D5GfU4Ja.js → browser-tab-DQJLMN11.js} +1 -1
- package/dist/web/assets/{chat-tab-BJeNwwUM.js → chat-tab-xp4ZqCGD.js} +4 -4
- package/dist/web/assets/{code-editor-CTjgdXh2.js → code-editor-De6Xs-Kq.js} +1 -1
- package/dist/web/assets/{database-viewer-QzEuetE6.js → database-viewer-CExMyEmq.js} +1 -1
- package/dist/web/assets/{diff-viewer-CvZ06EAH.js → diff-viewer-C7EECdwr.js} +1 -1
- package/dist/web/assets/git-graph-DqFYj10H.js +1 -0
- package/dist/web/assets/index-CiuAeXR3.js +37 -0
- package/dist/web/assets/keybindings-store-DYgvd7L0.js +1 -0
- package/dist/web/assets/{markdown-renderer-BVxlq4zO.js → markdown-renderer-CnBEa3kk.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DP0FOQOa.js → postgres-viewer-DvMnxJ7g.js} +1 -1
- package/dist/web/assets/{settings-tab-CcmhnYpw.js → settings-tab-C0ltpIWq.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4a4hHLZk.js → sqlite-viewer-DR9KKOhW.js} +1 -1
- package/dist/web/assets/tab-store-BJw7OCmy.js +1 -0
- package/dist/web/assets/{terminal-tab-CKsBIgnq.js → terminal-tab-B__sTLzq.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BwIb9BHq.js → use-monaco-theme-BZiSwNRE.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +126 -0
- package/docs/project-changelog.md +36 -1
- package/docs/system-architecture.md +35 -3
- package/package.json +1 -1
- package/src/server/index.ts +5 -1
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/workspace.ts +35 -0
- package/src/services/db.service.ts +37 -1
- package/src/services/supervisor.ts +14 -10
- package/src/web/app.tsx +37 -35
- package/src/web/hooks/use-url-sync.ts +173 -21
- package/src/web/stores/panel-store.ts +63 -9
- package/src/web/stores/panel-utils.ts +145 -3
- package/dist/web/assets/git-graph-BQqdvSjX.js +0 -1
- package/dist/web/assets/index-5a-tMkk5.js +0 -37
- package/dist/web/assets/keybindings-store-zY8zbJ2c.js +0 -1
- 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.
|
|
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 (
|
|
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
|
|
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
package/src/server/index.ts
CHANGED
|
@@ -360,12 +360,16 @@ if (process.argv.includes("__serve__")) {
|
|
|
360
360
|
|
|
361
361
|
// Sync externally-started tunnel URL + PID into tunnelService
|
|
362
362
|
// so GET /api/tunnel reflects the correct state and Share button doesn't start a duplicate.
|
|
363
|
+
// Also write server version to status.json so supervisor heartbeat reports the actual running version.
|
|
363
364
|
try {
|
|
364
365
|
const { resolve: r } = await import("node:path");
|
|
365
366
|
const { homedir: h } = await import("node:os");
|
|
366
|
-
const { readFileSync: rf } = await import("node:fs");
|
|
367
|
+
const { readFileSync: rf, writeFileSync: wf } = await import("node:fs");
|
|
367
368
|
const statusFile = r(h(), ".ppm", "status.json");
|
|
368
369
|
const status = JSON.parse(rf(statusFile, "utf-8"));
|
|
370
|
+
// Write running server version — source of truth for heartbeat
|
|
371
|
+
status.serverVersion = VERSION;
|
|
372
|
+
wf(statusFile, JSON.stringify(status));
|
|
369
373
|
if (status.shareUrl) {
|
|
370
374
|
const { tunnelService } = await import("../services/tunnel.service.ts");
|
|
371
375
|
tunnelService.setExternalUrl(status.shareUrl);
|
|
@@ -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 =
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -451,16 +451,20 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
451
451
|
cloudUrl: device.cloud_url,
|
|
452
452
|
deviceId: device.device_id,
|
|
453
453
|
secretKey: device.secret_key,
|
|
454
|
-
heartbeatFn: () =>
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
454
|
+
heartbeatFn: () => {
|
|
455
|
+
const status = readStatus();
|
|
456
|
+
return {
|
|
457
|
+
type: "heartbeat" as const,
|
|
458
|
+
tunnelUrl,
|
|
459
|
+
state: supervisorState,
|
|
460
|
+
// Use server-reported version (source of truth) with supervisor fallback
|
|
461
|
+
appVersion: (status.serverVersion as string) || VERSION,
|
|
462
|
+
availableVersion: (status.availableVersion as string) || null,
|
|
463
|
+
serverPid: serverChild?.pid ?? null,
|
|
464
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
465
|
+
timestamp: new Date().toISOString(),
|
|
466
|
+
};
|
|
467
|
+
},
|
|
464
468
|
});
|
|
465
469
|
|
|
466
470
|
// Handle commands from Cloud
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
6
|
-
*
|
|
25
|
+
* Parse the current URL to extract project name and tab info.
|
|
26
|
+
* Format: /project/{name}/{tabType}/{...identifier}
|
|
7
27
|
*/
|
|
8
|
-
export function parseUrlState():
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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 {
|
|
201
|
+
const { tabType, tabIdentifier } = parseUrlState();
|
|
202
|
+
if (!tabType) return;
|
|
203
|
+
|
|
204
|
+
isPopState.current = true;
|
|
59
205
|
const { tabs, setActiveTab } = useTabStore.getState();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|