Skip to content

Commit 8be743f

Browse files
committed
feat(web): sync menu navigation with url
1 parent 0807dd8 commit 8be743f

3 files changed

Lines changed: 372 additions & 0 deletions

File tree

packages/app/src/web/app-ready-controller.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from "./app-ready-hooks.js"
3333
import { useProjectPortForwardsReset } from "./app-ready-port-forwards-hook.js"
3434
import { useSshLink } from "./app-ready-ssh-link-hook.js"
35+
import { useReadyUrlSync } from "./app-ready-url.js"
3536
import { isProjectMenu, menuScreen, outputScreen, projectPickerScreen, screenForMenu } from "./screen.js"
3637

3738
type ReadyControllerArgs = {
@@ -119,6 +120,11 @@ const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => {
119120
}
120121

121122
const useReadySideEffects = (args: ReadySideEffectsArgs) => {
123+
useReadyUrlSync({
124+
currentMenu: args.currentMenu,
125+
dashboard: args.dashboard,
126+
state: args.state
127+
})
122128
useProjectSyncEffects(args)
123129
useReadyResetEffects(args)
124130
useSshLink({
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { useEffect, useRef } from "react"
2+
3+
import type { DashboardData } from "./api.js"
4+
import type { BrowserShortcutArgs } from "./app-ready-shortcut-runtime.js"
5+
import { browserMenuIndex, browserMenuItems, type BrowserMenuTag } from "./menu.js"
6+
import { type BrowserScreen, isProjectMenu, menuScreen, outputScreen, screenForMenu } from "./screen.js"
7+
import type { ActiveTerminalSession } from "./terminal.js"
8+
9+
type ReadyUrlNavigation = {
10+
readonly activeScreen: BrowserScreen
11+
readonly menu: BrowserMenuTag
12+
readonly projectNavigationArmed: boolean
13+
readonly selectedProjectId: string | null
14+
}
15+
16+
type ReadyUrlSyncArgs = {
17+
readonly currentMenu: BrowserMenuTag
18+
readonly dashboard: DashboardData
19+
readonly state: Pick<
20+
BrowserShortcutArgs,
21+
| "activeScreen"
22+
| "selectedProjectId"
23+
| "setActiveScreen"
24+
| "setProjectNavigationArmed"
25+
| "setSelectedMenuIndex"
26+
| "setSelectedProjectId"
27+
| "terminalSession"
28+
>
29+
}
30+
31+
type ReadyUrlPathArgs = {
32+
readonly activeScreen: BrowserScreen
33+
readonly currentMenu: BrowserMenuTag
34+
readonly selectedProjectId: string | null
35+
readonly selectedProjectSummary: DashboardData["projects"][number] | undefined
36+
readonly terminalSession: ActiveTerminalSession | null
37+
}
38+
39+
const menuSlugs: Readonly<Record<BrowserMenuTag, string>> = {
40+
Auth: "auth",
41+
Browser: "browser",
42+
Create: "create",
43+
Delete: "delete",
44+
Down: "down",
45+
DownAll: "down-all",
46+
Info: "info",
47+
Logs: "logs",
48+
Ports: "ports",
49+
ProjectAuth: "project-auth",
50+
Quit: "quit",
51+
Select: "select",
52+
Status: "status"
53+
}
54+
55+
const menuBySlug = new Map<string, BrowserMenuTag>(
56+
browserMenuItems.map(({ tag }) => [menuSlugs[tag], tag])
57+
)
58+
59+
const reservedPathPrefixes: ReadonlyArray<string> = ["/api/", "/assets/", "/b/", "/p/"]
60+
61+
const isSshLinkUrl = (url: URL): boolean =>
62+
url.pathname.startsWith("/ssh/") || ((url.searchParams.get("ssh") ?? "").trim().length > 0)
63+
64+
const isReservedPath = (pathname: string): boolean =>
65+
pathname === "/" || pathname === "/index.html" || reservedPathPrefixes.some((prefix) => pathname.startsWith(prefix))
66+
67+
const encodePathTail = (value: string): string =>
68+
value.split("/").map((segment) => encodeURIComponent(segment)).join("/")
69+
70+
const decodePathTail = (segments: ReadonlyArray<string>): string =>
71+
segments.map((segment) => decodeURIComponent(segment)).join("/").trim()
72+
73+
const projectToken = (project: DashboardData["projects"][number] | undefined, fallback: string | null): string | null =>
74+
project?.projectKey ?? fallback
75+
76+
const resolveProjectId = (
77+
projects: DashboardData["projects"],
78+
token: string
79+
): string | null => {
80+
const normalizedToken = token.trim()
81+
if (normalizedToken.length === 0) {
82+
return null
83+
}
84+
const project = projects.find((candidate) =>
85+
candidate.id === normalizedToken ||
86+
candidate.projectKey === normalizedToken ||
87+
candidate.displayName === normalizedToken
88+
)
89+
return project?.id ?? null
90+
}
91+
92+
const activeScreenFromMenu = (menu: BrowserMenuTag, outputRequested: boolean): BrowserScreen => {
93+
if (outputRequested && (menu === "Logs" || menu === "Status")) {
94+
return outputScreen()
95+
}
96+
if (menu === "ProjectAuth") {
97+
return { tag: "ProjectAuth" }
98+
}
99+
return screenForMenu(menu)
100+
}
101+
102+
const parseMenuUrl = (rest: ReadonlyArray<string>): ReadyUrlNavigation | null => {
103+
const menu = menuBySlug.get(rest[0] ?? "")
104+
return menu === undefined
105+
? null
106+
: {
107+
activeScreen: menuScreen(),
108+
menu,
109+
projectNavigationArmed: false,
110+
selectedProjectId: null
111+
}
112+
}
113+
114+
const parseMenuActionUrl = (
115+
rawSlug: string,
116+
rest: ReadonlyArray<string>,
117+
projects: DashboardData["projects"]
118+
): ReadyUrlNavigation | null => {
119+
const menu = menuBySlug.get(rawSlug)
120+
if (menu === undefined) {
121+
return null
122+
}
123+
124+
const outputRequested = rest.at(-1) === "output"
125+
const projectSegments = outputRequested ? rest.slice(0, -1) : rest
126+
const selectedProjectId = isProjectMenu(menu) ? resolveProjectId(projects, decodePathTail(projectSegments)) : null
127+
return {
128+
activeScreen: activeScreenFromMenu(menu, outputRequested),
129+
menu,
130+
projectNavigationArmed: false,
131+
selectedProjectId
132+
}
133+
}
134+
135+
export const parseReadyUrlNavigation = (
136+
href: string,
137+
projects: DashboardData["projects"]
138+
): ReadyUrlNavigation | null => {
139+
const url = new URL(href, "http://localhost")
140+
if (isSshLinkUrl(url) || isReservedPath(url.pathname)) {
141+
return null
142+
}
143+
144+
const segments = url.pathname.split("/").filter((segment) => segment.length > 0)
145+
if (segments.length === 0) {
146+
return null
147+
}
148+
149+
const rawSlug = segments[0]
150+
if (rawSlug === undefined) {
151+
return null
152+
}
153+
const rest = segments.slice(1)
154+
return rawSlug === "menu" ? parseMenuUrl(rest) : parseMenuActionUrl(rawSlug, rest, projects)
155+
}
156+
157+
export const readyUrlPath = (
158+
{
159+
activeScreen,
160+
currentMenu,
161+
selectedProjectId,
162+
selectedProjectSummary,
163+
terminalSession
164+
}: ReadyUrlPathArgs
165+
): string | null => {
166+
if (terminalSession?.browserProjectId !== undefined) {
167+
return `/ssh/${
168+
encodePathTail(
169+
projectToken(selectedProjectSummary, terminalSession.browserProjectId) ?? terminalSession.browserProjectId
170+
)
171+
}`
172+
}
173+
174+
const slug = menuSlugs[currentMenu]
175+
if (activeScreen.tag === "Menu") {
176+
return `/menu/${slug}`
177+
}
178+
179+
if (!isProjectMenu(currentMenu)) {
180+
return `/${slug}`
181+
}
182+
183+
const token = projectToken(selectedProjectSummary, selectedProjectId)
184+
const projectSuffix = token === null ? "" : `/${encodePathTail(token)}`
185+
const outputSuffix = activeScreen.tag === "Output" ? "/output" : ""
186+
return `/${slug}${projectSuffix}${outputSuffix}`
187+
}
188+
189+
const selectedProjectSummary = ({ dashboard, state }: ReadyUrlSyncArgs) =>
190+
dashboard.projects.find((project) => project.id === state.selectedProjectId)
191+
192+
const applyReadyUrlNavigation = (
193+
args: ReadyUrlSyncArgs,
194+
skipNextWriteRef: { current: boolean }
195+
): void => {
196+
const next = parseReadyUrlNavigation(globalThis.location.href, args.dashboard.projects)
197+
if (next === null) {
198+
return
199+
}
200+
skipNextWriteRef.current = true
201+
args.state.setSelectedMenuIndex(browserMenuIndex(next.menu))
202+
args.state.setActiveScreen(next.activeScreen)
203+
args.state.setProjectNavigationArmed(next.projectNavigationArmed)
204+
args.state.setSelectedProjectId(next.selectedProjectId)
205+
}
206+
207+
const writeReadyUrl = (
208+
args: ReadyUrlSyncArgs,
209+
skipInitialWriteRef: { current: boolean },
210+
skipNextWriteRef: { current: boolean }
211+
) => {
212+
if (skipInitialWriteRef.current) {
213+
skipInitialWriteRef.current = false
214+
return
215+
}
216+
if (skipNextWriteRef.current) {
217+
skipNextWriteRef.current = false
218+
return
219+
}
220+
221+
const currentUrl = new URL(globalThis.location.href)
222+
if (isSshLinkUrl(currentUrl) && args.state.terminalSession === null) {
223+
return
224+
}
225+
226+
const path = readyUrlPath({
227+
activeScreen: args.state.activeScreen,
228+
currentMenu: args.currentMenu,
229+
selectedProjectId: args.state.selectedProjectId,
230+
selectedProjectSummary: selectedProjectSummary(args),
231+
terminalSession: args.state.terminalSession
232+
})
233+
if (path === null || `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` === path) {
234+
return
235+
}
236+
globalThis.history.replaceState(globalThis.history.state, "", path)
237+
}
238+
239+
export const useReadyUrlSync = (args: ReadyUrlSyncArgs) => {
240+
const argsRef = useRef(args)
241+
const skipInitialWriteRef = useRef(true)
242+
const skipNextWriteRef = useRef(false)
243+
244+
argsRef.current = args
245+
246+
useEffect(() => {
247+
// URL reads are limited to initial load and browser back/forward. Normal clicks
248+
// and arrow navigation write state to the URL instead of being overwritten by it.
249+
const applyCurrentLocation = () => {
250+
applyReadyUrlNavigation(argsRef.current, skipNextWriteRef)
251+
}
252+
253+
applyCurrentLocation()
254+
const onPopState = applyCurrentLocation
255+
globalThis.addEventListener("popstate", onPopState)
256+
return () => {
257+
globalThis.removeEventListener("popstate", onPopState)
258+
}
259+
}, [])
260+
261+
useEffect(() => {
262+
writeReadyUrl(args, skipInitialWriteRef, skipNextWriteRef)
263+
}, [
264+
args.state.activeScreen,
265+
args.currentMenu,
266+
args.state.selectedProjectId,
267+
selectedProjectSummary(args)?.projectKey,
268+
args.state.terminalSession,
269+
args.state.terminalSession?.browserProjectId
270+
])
271+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import type { DashboardData } from "../../src/web/api.js"
4+
import { parseReadyUrlNavigation, readyUrlPath } from "../../src/web/app-ready-url.js"
5+
6+
const dashboard: DashboardData = {
7+
apiBaseUrl: "/api",
8+
health: {
9+
cwd: "/repo",
10+
ok: true,
11+
projectsRoot: "/home/dev/.docker-git",
12+
revision: null
13+
},
14+
projects: [
15+
{
16+
clonedOnHostname: "host",
17+
displayName: "octocat/hello-world",
18+
id: "project-1",
19+
projectKey: "octocat/hello-world",
20+
repoRef: "main",
21+
repoUrl: "https://github.com/octocat/Hello-World.git",
22+
sshSessions: 0,
23+
startedAtEpochMs: null,
24+
startedAtIso: null,
25+
status: "running",
26+
statusLabel: "Up"
27+
}
28+
]
29+
}
30+
31+
const selectedProjectSummary = dashboard.projects[0]
32+
33+
describe("app ready URL state", () => {
34+
it("renders menu tab highlights as copyable URLs", () => {
35+
expect(readyUrlPath({
36+
activeScreen: { tag: "Menu" },
37+
currentMenu: "Browser",
38+
selectedProjectId: null,
39+
selectedProjectSummary: undefined,
40+
terminalSession: null
41+
})).toBe("/menu/browser")
42+
})
43+
44+
it("renders selected project tabs as readable deep links", () => {
45+
expect(readyUrlPath({
46+
activeScreen: { tag: "ProjectPicker" },
47+
currentMenu: "Browser",
48+
selectedProjectId: "project-1",
49+
selectedProjectSummary,
50+
terminalSession: null
51+
})).toBe("/browser/octocat/hello-world")
52+
})
53+
54+
it("renders active SSH project terminals as SSH deep links", () => {
55+
expect(readyUrlPath({
56+
activeScreen: { tag: "ProjectPicker" },
57+
currentMenu: "Select",
58+
selectedProjectId: "project-1",
59+
selectedProjectSummary,
60+
terminalSession: {
61+
browserProjectId: "project-1",
62+
closePath: "/projects/project-1/terminal-sessions/session-1",
63+
exitMessage: "done",
64+
header: "SSH terminal: octocat/hello-world",
65+
pendingDeleteMessage: "closed",
66+
readyMessage: "ready",
67+
session: {
68+
createdAt: "2026-04-15T00:00:00.000Z",
69+
id: "session-1",
70+
projectId: "project-1",
71+
sshCommand: "ssh dev@127.0.0.1",
72+
status: "attached"
73+
},
74+
subtitle: "ssh dev@127.0.0.1",
75+
websocketPath: "/projects/project-1/terminal-sessions/session-1/ws"
76+
}
77+
})).toBe("/ssh/octocat/hello-world")
78+
})
79+
80+
it("parses project tab URLs back into app navigation state", () => {
81+
expect(parseReadyUrlNavigation("https://docker-git.local/browser/octocat/hello-world", dashboard.projects)).toEqual(
82+
{
83+
activeScreen: { tag: "ProjectPicker" },
84+
menu: "Browser",
85+
projectNavigationArmed: false,
86+
selectedProjectId: "project-1"
87+
}
88+
)
89+
})
90+
91+
it("keeps /ssh links owned by SSH auto-connect flow", () => {
92+
expect(parseReadyUrlNavigation("https://docker-git.local/ssh/octocat/hello-world", dashboard.projects)).toBeNull()
93+
expect(parseReadyUrlNavigation("https://docker-git.local/?ssh=octocat/hello-world", dashboard.projects)).toBeNull()
94+
})
95+
})

0 commit comments

Comments
 (0)