diff --git a/src/main/ipc/workspace-ports.ts b/src/main/ipc/workspace-ports.ts index 38af074ee9d..ac0fd463d18 100644 --- a/src/main/ipc/workspace-ports.ts +++ b/src/main/ipc/workspace-ports.ts @@ -5,6 +5,8 @@ import type { WorkspacePortAdvertisedUrlChangedEvent, WorkspacePortKillRequest, WorkspacePortKillResult, + WorkspacePortPageProbeRequest, + WorkspacePortPageProbeResult, WorkspacePortScanRequest, WorkspacePortScanResult } from '../../shared/workspace-ports' @@ -13,6 +15,7 @@ import { killWorkspacePort, scanWorkspacePortProbes } from '../ports/workspace-port-ownership' +import { probeWorkspacePortPage } from '../ports/workspace-port-page-probe' type WorkspacePortHandlersOptions = { advertisedUrlEvents?: Pick @@ -40,6 +43,7 @@ export function registerWorkspacePortHandlers( ipcMain.removeHandler('workspacePorts:scan') ipcMain.removeHandler('workspacePorts:kill') + ipcMain.removeHandler('workspacePorts:probePage') ipcMain.handle( 'workspacePorts:scan', (_event, rawArgs?: unknown): Promise => { @@ -76,6 +80,14 @@ export function registerWorkspacePortHandlers( return killWorkspacePort(worktrees, args) } ) + + ipcMain.handle( + 'workspacePorts:probePage', + async (_event, rawArgs?: unknown): Promise => { + const args = parseProbePageRequest(rawArgs) + return args ? probeWorkspacePortPage(args) : { kind: 'not-page' } + } + ) } function broadcastWorkspacePortAdvertisedUrlChanged( @@ -118,3 +130,16 @@ function parseKillRequest(value: unknown): WorkspacePortKillRequest | null { port } } + +function parseProbePageRequest(value: unknown): WorkspacePortPageProbeRequest | null { + if (!value || typeof value !== 'object') { + return null + } + const host = 'host' in value ? value.host : undefined + const port = 'port' in value ? value.port : undefined + if (typeof host !== 'string' || typeof port !== 'number' || !Number.isSafeInteger(port)) { + return null + } + const protocol = 'protocol' in value && value.protocol === 'https' ? 'https' : 'http' + return { host, port, protocol } +} diff --git a/src/main/ports/workspace-port-page-probe.test.ts b/src/main/ports/workspace-port-page-probe.test.ts new file mode 100644 index 00000000000..2e635e32f63 --- /dev/null +++ b/src/main/ports/workspace-port-page-probe.test.ts @@ -0,0 +1,89 @@ +import http from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { probeWorkspacePortPage } from './workspace-port-page-probe' + +const ICON = Buffer.from('') +const servers: http.Server[] = [] + +async function serve(handler: http.RequestListener): Promise { + const server = http.createServer(handler) + servers.push(server) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP test server') + } + return address.port +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))) + ) +}) + +describe('probeWorkspacePortPage', () => { + it('reads the title and linked icon of a page a dev server serves', async () => { + const port = await serve((request, response) => { + if (request.url === '/assets/icon.svg') { + response.writeHead(200, { 'content-type': 'image/svg+xml' }).end(ICON) + return + } + response + .writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + .end( + '\n Vite & React ' + ) + }) + + await expect( + probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' }) + ).resolves.toEqual({ + kind: 'page', + title: 'Vite & React', + faviconDataUrl: `data:image/svg+xml;base64,${ICON.toString('base64')}` + }) + }) + + it('follows a same-origin redirect to the page', async () => { + const port = await serve((request, response) => { + if (request.url === '/') { + response.writeHead(302, { location: '/login' }).end() + return + } + response.writeHead(200, { 'content-type': 'text/html' }).end('Sign in') + }) + + await expect( + probeWorkspacePortPage({ host: '127.0.0.1', port, protocol: 'http' }) + ).resolves.toMatchObject({ kind: 'page', title: 'Sign in', faviconDataUrl: null }) + }) + + it('leaves out APIs and error pages', async () => { + const api = await serve((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}') + }) + const notFound = await serve((_request, response) => { + response.writeHead(404, { 'content-type': 'text/html' }).end('Cannot GET /') + }) + + for (const port of [api, notFound]) { + await expect( + probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' }) + ).resolves.toEqual({ kind: 'not-page' }) + } + }) + + it('never reaches past loopback', async () => { + let requested = false + const port = await serve((_request, response) => { + requested = true + response.writeHead(200, { 'content-type': 'text/html' }).end('x') + }) + + await expect( + probeWorkspacePortPage({ host: 'example.com', port, protocol: 'http' }) + ).resolves.toEqual({ kind: 'not-page' }) + expect(requested).toBe(false) + }) +}) diff --git a/src/main/ports/workspace-port-page-probe.ts b/src/main/ports/workspace-port-page-probe.ts new file mode 100644 index 00000000000..6feb44022df --- /dev/null +++ b/src/main/ports/workspace-port-page-probe.ts @@ -0,0 +1,186 @@ +import http from 'node:http' +import https from 'node:https' +import { + LOOPBACK_LOCALHOST_HOSTS, + connectableLoopbackHost, + normalizeLocalhostHostname +} from '../../shared/localhost-worktree-labels' +import type { + WorkspacePortPageProbeRequest, + WorkspacePortPageProbeResult +} from '../../shared/workspace-ports' + +const PROBE_TIMEOUT_MS = 1500 +const MAX_PAGE_BYTES = 256 * 1024 +const MAX_FAVICON_BYTES = 64 * 1024 +const MAX_REDIRECTS = 3 +const NOT_PAGE: WorkspacePortPageProbeResult = { kind: 'not-page' } + +type ProbeResponse = { + status: number + location: string | undefined + contentType: string + body: Buffer +} + +/** GETs a loopback listener's root and reports whether it serves an HTML page, with its title and icon. */ +export async function probeWorkspacePortPage( + request: WorkspacePortPageProbeRequest +): Promise { + const origin = loopbackOrigin(request) + if (!origin) { + return NOT_PAGE + } + let url = new URL('/', origin) + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { + const response = await fetchLoopback(url, MAX_PAGE_BYTES) + if (!response) { + return NOT_PAGE + } + if (response.status >= 300 && response.status < 400 && response.location) { + const next = new URL(response.location, url) + // Why same origin only: the probe answers for this listener, not wherever it points. + if (next.origin !== url.origin) { + return NOT_PAGE + } + url = next + continue + } + if (response.status < 200 || response.status >= 300) { + return NOT_PAGE + } + if (!response.contentType.toLowerCase().includes('text/html')) { + return NOT_PAGE + } + const html = response.body.toString('utf8') + return { + kind: 'page', + title: extractHtmlTitle(html), + faviconDataUrl: await fetchFaviconDataUrl(html, url) + } + } + return NOT_PAGE +} + +function loopbackOrigin({ host, port, protocol }: WorkspacePortPageProbeRequest): string | null { + const bare = normalizeLocalhostHostname(host) + if (!LOOPBACK_LOCALHOST_HOSTS.has(bare) || !Number.isInteger(port) || port < 1 || port > 65535) { + return null + } + const connectHost = connectableLoopbackHost(bare) + const urlHost = connectHost.includes(':') ? `[${connectHost}]` : connectHost + return `${protocol === 'https' ? 'https' : 'http'}://${urlHost}:${port}` +} + +function fetchLoopback(url: URL, maxBytes: number): Promise { + return new Promise((resolve) => { + const client = url.protocol === 'https:' ? https : http + const request = client.get( + url, + { + agent: false, + headers: { accept: 'text/html,image/*;q=0.9,*/*;q=0.8' }, + // Why: loopback dev servers use self-signed certificates, and only a title and icon leave. + rejectUnauthorized: false, + timeout: PROBE_TIMEOUT_MS + }, + (response) => { + const chunks: Buffer[] = [] + let size = 0 + const finish = (): void => + resolve({ + status: response.statusCode ?? 0, + location: response.headers.location, + contentType: response.headers['content-type'] ?? '', + body: Buffer.concat(chunks) + }) + response.on('data', (chunk: Buffer) => { + size += chunk.length + chunks.push(chunk) + // A title sits near the top, so a large page is read only up to the cap. + if (size >= maxBytes) { + response.destroy() + finish() + } + }) + response.on('end', finish) + response.on('error', () => resolve(null)) + } + ) + request.on('timeout', () => request.destroy()) + request.on('error', () => resolve(null)) + }) +} + +function extractHtmlTitle(html: string): string | null { + const match = /]*>([\s\S]*?)<\/title>/i.exec(html) + const title = match ? decodeHtmlEntities(match[1]).replace(/\s+/g, ' ').trim() : '' + return title ? title.slice(0, 120) : null +} + +function decodeHtmlEntities(text: string): string { + const named: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, body: string) => { + if (body[0] === '#') { + const code = + body[1] === 'x' || body[1] === 'X' + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10) + return Number.isInteger(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : entity + } + return named[body.toLowerCase()] ?? entity + }) +} + +function findIconHref(html: string): string | null { + for (const tag of html.match(/]*>/gi) ?? []) { + const attributes = new Map() + for (const [, name, double, single, bare] of tag.matchAll( + /\b(rel|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi + )) { + attributes.set(name.toLowerCase(), double ?? single ?? bare ?? '') + } + const rel = attributes.get('rel')?.toLowerCase().split(/\s+/) ?? [] + const href = attributes.get('href') + if (rel.includes('icon') && href) { + return decodeHtmlEntities(href) + } + } + return null +} + +async function fetchFaviconDataUrl(html: string, pageUrl: URL): Promise { + const href = findIconHref(html) ?? '/favicon.ico' + if (href.startsWith('data:image/')) { + return href.length <= MAX_FAVICON_BYTES * 2 ? href : null + } + let iconUrl: URL + try { + iconUrl = new URL(href, pageUrl) + } catch { + return null + } + if (iconUrl.origin !== pageUrl.origin) { + return null + } + const response = await fetchLoopback(iconUrl, MAX_FAVICON_BYTES + 1) + if ( + !response || + response.status !== 200 || + response.body.length === 0 || + response.body.length > MAX_FAVICON_BYTES + ) { + return null + } + const contentType = response.contentType.split(';')[0].trim().toLowerCase() + // Why the extension only for untyped bodies: SPA dev servers answer every path with index.html. + const untyped = contentType === '' || contentType === 'application/octet-stream' + const imageType = contentType.startsWith('image/') + ? contentType + : untyped && iconUrl.pathname.toLowerCase().endsWith('.ico') + ? 'image/x-icon' + : null + return imageType ? `data:${imageType};base64,${response.body.toString('base64')}` : null +} diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index b8813593fbe..3cb0fc357e4 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -2,9 +2,11 @@ import { defineMethod } from '../core' import { WorkspacePortKillHostParams, WorkspacePortKillParams, + WorkspacePortProbePageParams, WorkspacePortScanHostParams, WorkspacePortScanParams } from '../../../../shared/rpc-contract/workspace-ports-params' +import { probeWorkspacePortPage } from '../../../ports/workspace-port-page-probe' export const WORKSPACE_PORT_METHODS = [ defineMethod({ @@ -24,6 +26,12 @@ export const WORKSPACE_PORT_METHODS = [ port: params.port }) }), + defineMethod({ + name: 'workspacePorts.probePage', + permission: 'workspace', + params: WorkspacePortProbePageParams, + handler: async (params) => probeWorkspacePortPage(params) + }), defineMethod({ name: 'workspacePorts.scanHost', permission: 'workspace', diff --git a/src/preload/api/workspace-port-api.ts b/src/preload/api/workspace-port-api.ts index 8c0166695ac..06c91c645ea 100644 --- a/src/preload/api/workspace-port-api.ts +++ b/src/preload/api/workspace-port-api.ts @@ -6,6 +6,8 @@ import type { WorkspacePortAdvertisedUrlChangedEvent, WorkspacePortKillRequest, WorkspacePortKillResult, + WorkspacePortPageProbeRequest, + WorkspacePortPageProbeResult, WorkspacePortScanRequest, WorkspacePortScanResult } from '../../shared/workspace-ports' @@ -13,6 +15,7 @@ import type { export type WorkspacePortsApi = { scan: (args: WorkspacePortScanRequest) => Promise kill: (args: WorkspacePortKillRequest) => Promise + probePage: (args: WorkspacePortPageProbeRequest) => Promise onAdvertisedUrlChanged: ( callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void ) => () => void diff --git a/src/preload/api/workspace-ports-bridge.ts b/src/preload/api/workspace-ports-bridge.ts index 6cb4c859ed3..fba9a274adf 100644 --- a/src/preload/api/workspace-ports-bridge.ts +++ b/src/preload/api/workspace-ports-bridge.ts @@ -5,6 +5,7 @@ import type { PreloadApi } from '../api-types' export const workspacePortsApi = { scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + probePage: (args) => ipcRenderer.invoke('workspacePorts:probePage', args), onAdvertisedUrlChanged: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index 74290aa68b9..a12e4fe0594 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -40,6 +40,8 @@ import { isPageDocumentNavigationStart } from './host-guest/browser-page-webview import { BrowserLoadFailureOverlay } from './navigate/browser-load-failure-overlay' import { useClientHostedPageUrlSubmission } from './navigate/use-client-hosted-page-url-submission' import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' +import { isBlankBrowserUrl } from '@/lib/browser-palette-search' +import { BrowserNewTabPage } from './new-tab/BrowserNewTabPage' import { useBrowserPageReloadActions } from './navigate/use-browser-page-reload-actions' import { resolveActiveBrowserLoadFailure } from './navigate/browser-load-failure-for-url' import { consumeBrowserPageDeferredNavigation } from './navigate/browser-page-deferred-navigation' @@ -428,6 +430,8 @@ export function ClientHostedBrowserPagePane({ lastCommittedUrl={browserTab.url} placementHostClientId={browserHostClientId} /> + ) : isBlankBrowserUrl(browserTab.url) ? ( + ) : null} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index be2a1b5b717..35fdd3b27bf 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -217,8 +217,11 @@ export default function BrowserAddressBar({ } inputRef.current?.select() openedAtRef.current = Date.now() - setOpen(true) - }, [inputRef]) + // Why: an empty bar is a blank tab, whose New Tab page already lists what the dropdown would. + if (value !== '') { + setOpen(true) + } + }, [inputRef, value]) const handleBlur = useCallback(() => { // Why: delay close so that clicking a suggestion item registers before diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx index 3a9403f35fd..a0f60471edb 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx @@ -1,6 +1,5 @@ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' import { BrowserPageZoomIndicator } from './browser-page-zoom-indicator' -import { Globe } from 'lucide-react' import { translate } from '@/i18n/i18n' import { toHttpsRecoveryUrl } from '../../../../../shared/browser-url' import type { @@ -11,6 +10,7 @@ import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-gu import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay' import { useSshWorkspaceProbeSkipRecheck } from '../use-ssh-workspace-browser-route' import BrowserFind from './BrowserFind' +import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage' import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays' import type { MarkupModeController } from '../annotate/useMarkupMode' import type { GrabModeHook } from '../annotate/useGrabMode' @@ -126,26 +126,7 @@ export function BrowserPageViewportOverlays({ } /> ) : null} - {isBlankTab ? ( -
-
-
- -
-
-

- {translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')} -

-

- {translate( - 'auto.components.browser.pane.BrowserPane.f796c774a4', - 'Type a URL above to start browsing.' - )} -

-
-
-
- ) : null} + {isBlankTab ? : null} ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index 2fec9a003a7..c3ef0a5f9e9 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -408,7 +408,7 @@ describe.each([ expect(document.activeElement).toBe(adopted) expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([3, 7]) expect(adopted.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByText('Example docs')).not.toBeNull() + expect(screen.getByRole('option', { name: /Example docs/ })).not.toBeNull() }) // Why the draft alone is not enough here: while a suggestion is previewed the input holds that @@ -498,7 +498,7 @@ describe.each([ expect(adopted.value).toBe('example.int') expect(document.activeElement).toBe(adopted) expect(adopted.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('Example docs')).toBeNull() + expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull() }) // Why: the swap tears the bar down and puts it back inside one React commit. A pane that goes @@ -531,7 +531,7 @@ describe.each([ // over the page, by a handover they never asked for. expect(document.activeElement).not.toBe(adopted) expect(adopted.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText('Example docs')).toBeNull() + expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull() }) // The pane is no longer keyed on the generation, so a bump re-runs the attach rather than diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts index 05b8c5d3495..9bb5ebef7f7 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts @@ -1,4 +1,5 @@ import { ORCA_BROWSER_PAGE_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { isBlankBrowserUrl } from '@/lib/browser-palette-search' export function createBrowserClientPageRetainedRoot(document: Document): HTMLDivElement { const root = document.createElement('div') @@ -33,6 +34,21 @@ export function createBrowserClientPageRetainedHost( return host } +/** + * The guest's `load-commit` listener that hides its host on a blank page. Why the host and not the + * guest: the host is a click target above the pane, so a blank tab must drop both its paint and its + * hit testing for the New Tab page underneath. + */ +export function hideRetainedHostWhileGuestIsBlank( + host: HTMLDivElement +): (event: Electron.LoadCommitEvent) => void { + return (event) => { + if (event.isMainFrame) { + host.style.visibility = isBlankBrowserUrl(event.url) ? 'hidden' : '' + } + } +} + export function createBrowserClientPageWebview(options: { createWebview?: () => Electron.WebviewTag document: Document diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts index d9cda9d2366..fae46296018 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts @@ -92,6 +92,25 @@ describe('browser client page retained registry', () => { await expect(mounted).resolves.toEqual({ webContentsId: 41 }) }) + // The host is a click target painted above the pane, so the New Tab page beneath a blank guest + // is only usable while the whole host is hidden, not just the guest. + it('hides the retained host while its guest shows a blank page', async () => { + const { registry, webviews } = createRig() + void registry.mountPage(PAGE) + const webview = webviews[0]! + const host = webview.parentElement! + const commit = (url: string, isMainFrame = true): void => { + webview.dispatchEvent(Object.assign(new Event('load-commit'), { url, isMainFrame })) + } + + commit('about:blank') + expect(host.style.visibility).toBe('hidden') + commit('https://example.com/frame', false) + expect(host.style.visibility).toBe('hidden') + commit('https://example.com/') + expect(host.style.visibility).toBe('') + }) + // The guest lives in a fixed-position overlay, never inside its pane, so nothing in the DOM says // which row a `` belongs to unless the host carries the page id. The paired restart e2e // binds its marker read to exactly this stamp. diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts index 3a4114605f0..f0241dc5c82 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts @@ -7,6 +7,7 @@ import { createBrowserClientPageRetainedRoot, createBrowserClientPageWebview, hasBrowserClientPageAttachedGuest, + hideRetainedHostWhileGuestIsBlank, readBrowserClientPageAttachedGuestId } from './browser-client-page-retained-elements' import { browserClientPageRetainedKey } from './browser-client-page-retained-key' @@ -196,13 +197,15 @@ export class BrowserClientPageRetainedRegistry { onAttached, onReady, onDestroyed, - onRendererGone + onRendererGone, + onLoadCommit: hideRetainedHostWhileGuestIsBlank(host) }) page.releaseDragPassthroughSurface = enrolRetainedHostDragPassthrough(page) webview.addEventListener('did-attach', onAttached) webview.addEventListener('dom-ready', onReady) webview.addEventListener('destroyed', onDestroyed) webview.addEventListener('render-process-gone', onRendererGone) + webview.addEventListener('load-commit', page.onLoadCommit) this.pages.set(key, page) this.partitionCounts.set( identity.partition, @@ -286,6 +289,7 @@ export class BrowserClientPageRetainedRegistry { page.webview.removeEventListener('dom-ready', page.onReady) page.webview.removeEventListener('destroyed', page.onDestroyed) page.webview.removeEventListener('render-process-gone', page.onRendererGone) + page.webview.removeEventListener('load-commit', page.onLoadCommit) page.visibleAttachment = null page.webview.remove() page.host.remove() diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts index 4fd97bdd4fa..20c54fae727 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-state.ts @@ -18,5 +18,6 @@ export type BrowserClientRetainedRendererPage = { onReady: EventListener onDestroyed: EventListener onRendererGone: EventListener + onLoadCommit: (event: Electron.LoadCommitEvent) => void releaseDragPassthroughSurface: () => void } diff --git a/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx b/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx new file mode 100644 index 00000000000..98990192be6 --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/BrowserNewTabPage.tsx @@ -0,0 +1,231 @@ +import { useMemo } from 'react' +import { Globe } from 'lucide-react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useRepoById, useWorktreeById } from '@/store/selectors' +import { prepareBrowserHistoryEntries, rankBrowserTopSites } from '@/lib/browser-history-match' +import { BrowserFavicon } from '@/components/browser-favicon' +import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorkspaceLinkedItemRow } from '../../sidebar/workspace-hover-attachments' +import { BrowserNewTabTile } from './browser-new-tab-tile' +import { useBrowserNewTabLinkedItems } from './use-browser-new-tab-linked-items' +import { useBrowserNewTabServers, type BrowserNewTabServers } from './use-browser-new-tab-servers' +import { BrowserNewTabWorkspaceCard } from './browser-new-tab-workspace-card' +import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' + +const SUGGESTED_TILE_LIMIT = 8 +const NO_LINKED_ITEMS: WorkspaceLinkedItemRow[] = [] + +type BrowserNewTabServerContext = { + servers: BrowserNewTabServers + runtimeTarget: RuntimeClientTarget | null + /** False when this tab's page runs on another machine than the workspace's ports. */ + openInPlace: boolean +} + +/** The page a blank browser tab shows: this workspace's servers and links, then the sites you visit most. */ +export function BrowserNewTabPage({ + worktreeId, + onOpenUrl, + serversElsewhere = false +}: { + worktreeId: string + onOpenUrl: (url: string) => void + /** This tab's page runs on another machine than the workspace's ports, so they open on theirs. */ + serversElsewhere?: boolean +}): React.JSX.Element { + const worktree = useWorktreeById(worktreeId) + const repo = useRepoById(worktree?.repoId ?? null) ?? undefined + const runtimeTarget = useWorktreeRuntimeTarget(worktreeId) + const servers: BrowserNewTabServerContext = { + servers: useBrowserNewTabServers(worktreeId, runtimeTarget), + runtimeTarget, + openInPlace: !serversElsewhere + } + return worktree ? ( + + ) : ( + + ) +} + +function BrowserNewTabWorktreePage({ + worktree, + repo, + servers, + onOpenUrl +}: { + worktree: Worktree + repo: Repo | undefined + servers: BrowserNewTabServerContext + onOpenUrl: (url: string) => void +}): React.JSX.Element { + const linkedItems = useBrowserNewTabLinkedItems(worktree, repo) + return ( + + ) +} + +function BrowserNewTabPageLayout({ + linkedItems, + servers: { servers, runtimeTarget, openInPlace }, + onOpenUrl +}: { + linkedItems: WorkspaceLinkedItemRow[] + servers: BrowserNewTabServerContext + onOpenUrl: (url: string) => void +}): React.JSX.Element { + const browserUrlHistory = useAppStore((s) => s.browserUrlHistory) + const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry) + const restoreBrowserHistoryEntry = useAppStore((s) => s.restoreBrowserHistoryEntry) + const suggested = useMemo( + () => + rankBrowserTopSites({ + prepared: prepareBrowserHistoryEntries(browserUrlHistory), + limit: SUGGESTED_TILE_LIMIT + }), + [browserUrlHistory] + ) + const openableLinkedItems = linkedItems.filter((row) => row.url) + const hasWorkspaceRows = + Boolean(runtimeTarget && (servers.own.length > 0 || servers.others.length > 0)) || + openableLinkedItems.length > 0 + + const removeSuggestion = (entry: BrowserHistoryEntry): void => { + const removed = removeBrowserHistoryEntry(entry.normalizedUrl) + if (!removed) { + return + } + toast( + translate('browser.newTab.removedFromHistory', 'Removed “{{title}}” from history', { + title: suggestionLabel(removed) + }), + { + action: { + label: translate('browser.newTab.undo', 'Undo'), + onClick: () => restoreBrowserHistoryEntry(removed) + } + } + ) + } + + if (!hasWorkspaceRows && suggested.length === 0) { + return ( + +
+
+ +
+

+ {translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')} +

+

+ {translate( + 'browser.newTab.emptyHint', + 'Search or enter an address above. Sites you visit, dev servers running in this workspace, and its PR and issue will show up here.' + )} +

+
+
+ ) + } + + return ( + + {hasWorkspaceRows ? ( + + + + ) : null} + {suggested.length > 0 ? ( + + + {suggested.map((entry) => ( + + } + label={suggestionLabel(entry)} + tooltip={entry.title.trim() ? `${entry.title} · ${entry.url}` : entry.url} + onOpen={() => onOpenUrl(entry.url)} + onRemove={() => removeSuggestion(entry)} + removeLabel={translate('browser.newTab.removeSuggestion', 'Remove from history')} + /> + ))} + + + ) : null} + + ) +} + +function BrowserNewTabScroller({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
+
+ {children} +
+
+ ) +} + +function BrowserNewTabSection({ + label, + children +}: { + label: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+

+ {label} +

+ {children} +
+ ) +} + +function BrowserNewTabTileGrid({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( + // Why steps above the smallest panes: eight tiles land in full rows of 4 or 8. +
+ {children} +
+ ) +} + +function suggestionLabel(entry: BrowserHistoryEntry): string { + if (entry.title.trim()) { + return entry.title + } + try { + return new URL(entry.url).host.replace(/^www\./, '') + } catch { + return entry.url + } +} diff --git a/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-row.tsx b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-row.tsx new file mode 100644 index 00000000000..fd01b8f8829 --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-row.tsx @@ -0,0 +1,29 @@ +/** One row of the workspace card: an icon, a title over a detail line, and optional trailing state. */ +export function BrowserNewTabRow({ + icon, + title, + detail, + trailing, + onOpen +}: { + icon: React.ReactNode + title: string + detail: React.ReactNode + trailing?: React.ReactNode + onOpen: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-server-row.tsx b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-server-row.tsx new file mode 100644 index 00000000000..a6d3cd343ce --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-server-row.tsx @@ -0,0 +1,76 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { BrowserFavicon } from '@/components/browser-favicon' +import { useLocalhostLabelRouteForPort } from '@/lib/workspace-port-localhost-label-selector' +import { addressForPort } from '@/lib/workspace-port-urls' +import { + openWorkspacePortInBrowser, + resolveWorkspacePortBrowserUrl +} from '@/lib/workspace-port-actions' +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import { BrowserNewTabRow } from './browser-new-tab-row' +import type { BrowserNewTabServer } from './use-browser-new-tab-servers' + +export function BrowserNewTabServerRow({ + server, + runtimeTarget, + openInPlace, + showOwner = false, + onOpenUrl +}: { + server: BrowserNewTabServer + runtimeTarget: RuntimeClientTarget + /** False when this tab's page runs on another machine than the port, which only a new tab on the port's host can reach. */ + openInPlace: boolean + /** Names the worktree the server belongs to, for servers of other workspaces. */ + showOwner?: boolean + onOpenUrl: (url: string) => void +}): React.JSX.Element { + const { port, title, faviconDataUrl } = server + const localhostLabelRoute = useLocalhostLabelRouteForPort(port) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) + const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const address = addressForPort(port) + const context = showOwner && port.kind === 'workspace' ? port.owner.displayName : port.processName + + const open = async (): Promise => { + if (openInPlace) { + onOpenUrl(await resolveWorkspacePortBrowserUrl({ port, runtimeTarget, localhostLabelRoute })) + return + } + const result = await openWorkspacePortInBrowser({ + port, + runtimeTarget, + createBrowserTab, + setRemoteBrowserPageHandle, + localhostLabelRoute + }) + if (!result.ok) { + toast.error( + translate('browser.newTab.openServerFailed', 'Couldn’t open {{address}}', { address }), + { description: result.reason } + ) + } + } + + return ( + + } + title={title ?? port.processName ?? address} + detail={ + <> + {address} + {context ? ` · ${context}` : null} + + } + onOpen={() => void open()} + /> + ) +} diff --git a/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-tile.tsx b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-tile.tsx new file mode 100644 index 00000000000..ed9c03431b8 --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-tile.tsx @@ -0,0 +1,50 @@ +import { X } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +export function BrowserNewTabTile({ + icon, + label, + tooltip, + onOpen, + onRemove, + removeLabel +}: { + icon: React.ReactNode + label: string + tooltip: string + onOpen: () => void + onRemove?: () => void + removeLabel?: string +}): React.JSX.Element { + return ( +
+ + + + + + {tooltip} + + + {onRemove ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-workspace-card.tsx b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-workspace-card.tsx new file mode 100644 index 00000000000..c1b8897d257 --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/browser-new-tab-workspace-card.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react' +import { ChevronRight, CircleDot, GitMerge, GitPullRequest } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { JiraIcon } from '@/components/icons/JiraIcon' +import { getReviewStateTone } from '@/components/github/review-state-presentation' +import { workspaceAttachmentReferenceLabel } from '../../../../../shared/workspace-attachments' +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { HostedReviewState } from '../../../../../shared/hosted-review' +import type { CheckStatus } from '../../../../../shared/github/pull-request-types' +import { ReviewIcon } from '../../sidebar/worktree-review-helpers' +import { workspaceAttachmentLabel } from '../../sidebar/worktree-attachment-editing' +import type { WorkspaceLinkedItemRow } from '../../sidebar/workspace-hover-attachments' +import { BrowserNewTabRow } from './browser-new-tab-row' +import { BrowserNewTabServerRow } from './browser-new-tab-server-row' +import type { BrowserNewTabServers } from './use-browser-new-tab-servers' + +/** This workspace's running servers and linked reviews and tasks, then servers elsewhere on its host. */ +export function BrowserNewTabWorkspaceCard({ + servers, + linkedItems, + runtimeTarget, + openServersInPlace, + onOpenUrl +}: { + servers: BrowserNewTabServers + linkedItems: WorkspaceLinkedItemRow[] + runtimeTarget: RuntimeClientTarget | null + openServersInPlace: boolean + onOpenUrl: (url: string) => void +}): React.JSX.Element { + const [othersOpen, setOthersOpen] = useState(false) + const own = runtimeTarget ? servers.own : [] + const others = runtimeTarget ? servers.others : [] + const serverRow = (server: BrowserNewTabServers['own'][number], showOwner: boolean) => + runtimeTarget ? ( + + ) : null + + return ( +
+ {own.length > 0 || linkedItems.length > 0 ? ( +
+ {own.map((server) => serverRow(server, false))} + {own.length > 0 && linkedItems.length > 0 ? ( +
+ ) : null} + {linkedItems.map((row) => ( + + ))} +
+ ) : null} + {others.length > 0 ? ( + <> + + {othersOpen ? ( +
+ {others.map((server) => serverRow(server, true))} +
+ ) : null} + + ) : null} +
+ ) +} + +function LinkedItemRow({ + row, + onOpenUrl +}: { + row: WorkspaceLinkedItemRow + onOpenUrl: (url: string) => void +}): React.JSX.Element { + const { item, detail, live, url } = row + const reference = workspaceAttachmentReferenceLabel(item, { + pr: (number) => `#${number}`, + issue: (number) => `#${number}`, + mr: (number) => `!${number}` + }) + const status = live ? reviewChecksLabel(live.status) : linkedTaskState(row) + const reviewState = live?.state ? reviewStateLabel(live.state) : undefined + return ( + } + title={detail?.title || item.title || live?.title || workspaceAttachmentLabel(item)} + detail={ + <> + {reference} + {status ? ` · ${status}` : null} + + } + trailing={ + reviewState && live?.state ? ( + + {reviewState} + + ) : null + } + onOpen={() => { + if (url) { + onOpenUrl(url) + } + }} + /> + ) +} + +function LinkedItemIcon({ row }: { row: WorkspaceLinkedItemRow }): React.JSX.Element { + const { item, live } = row + if (live) { + return + } + if (item.provider === 'linear') { + return + } + if (item.provider === 'jira') { + return + } + if (item.type === 'mr') { + return + } + if (item.type === 'pr') { + return + } + return ( + + ) +} + +function reviewStateLabel(state: HostedReviewState): string { + if (state === 'draft') { + return translate('browser.newTab.state.draft', 'Draft') + } + if (state === 'merged') { + return translate('browser.newTab.state.merged', 'Merged') + } + if (state === 'closed') { + return translate('browser.newTab.state.closed', 'Closed') + } + return translate('browser.newTab.state.open', 'Open') +} + +function reviewChecksLabel(status: CheckStatus | undefined): string | undefined { + if (status === 'success') { + return translate('browser.newTab.checks.passing', 'Checks passing') + } + if (status === 'failure') { + return translate('browser.newTab.checks.failing', 'Checks failing') + } + if (status === 'pending') { + return translate('browser.newTab.checks.pending', 'Checks pending') + } + return undefined +} + +function linkedTaskState(row: WorkspaceLinkedItemRow): string | undefined { + const { detail, liveTask } = row + const stateName = + detail?.stateName ?? (liveTask && 'stateName' in liveTask ? liveTask.stateName : undefined) + if (stateName) { + return stateName + } + const issueState = linkedIssueState(row) + if (issueState === 'open') { + return translate('browser.newTab.state.open', 'Open') + } + if (issueState === 'closed') { + return translate('browser.newTab.state.closed', 'Closed') + } + return undefined +} + +function linkedIssueState({ + detail, + liveTask +}: Pick): 'open' | 'closed' | undefined { + return detail?.issueState ?? (liveTask && 'number' in liveTask ? liveTask.state : undefined) +} diff --git a/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-linked-items.ts b/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-linked-items.ts new file mode 100644 index 00000000000..86946c8bb33 --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-linked-items.ts @@ -0,0 +1,100 @@ +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { useWorktreeCardFoundation } from '../../sidebar/use-worktree-card-foundation' +import { useWorktreeCardReviewDetails } from '../../sidebar/use-worktree-card-review-details' +import { useWorktreeCardLinkedDetails } from '../../sidebar/use-worktree-card-linked-details' +import { useWorktreeCardLifecycleEffects } from '../../sidebar/use-worktree-card-lifecycle-effects' +import { + getWorkspaceLinkedItemRows, + type WorkspaceLinkedItemRow +} from '../../sidebar/workspace-hover-attachments' + +/** The worktree's linked reviews and tasks, fetched and resolved the way its sidebar card does. */ +export function useBrowserNewTabLinkedItems( + worktree: Worktree, + repo: Repo | undefined +): WorkspaceLinkedItemRow[] { + const foundation = useWorktreeCardFoundation({ worktree, repo }) + const review = useWorktreeCardReviewDetails({ + worktree, + repo, + settings: foundation.settings, + projectGroups: foundation.projectGroups, + cardProps: foundation.cardProps, + newCardStyle: foundation.newCardStyle + }) + const linked = useWorktreeCardLinkedDetails({ + worktree, + newCardStyle: foundation.newCardStyle, + deleteState: foundation.deleteState, + branch: review.branch, + issueEntry: review.issueEntry, + linearIssueEntry: review.linearIssueEntry, + linearIssueFallbackEntry: review.linearIssueFallbackEntry, + prDisplay: review.prDisplay + }) + // Why details open: the page shows titles and states up front, as the card's hover does. + useWorktreeCardLifecycleEffects({ + worktree, + repo, + isFolder: review.isFolder, + hostedReviewCacheKey: review.hostedReviewCacheKey, + cachedBranchFallbackGitHubPRNumber: review.cachedBranchFallbackGitHubPRNumber, + linkedGitLabMR: review.linkedGitLabMR, + linkedBitbucketPR: review.linkedBitbucketPR, + linkedAzureDevOpsPR: review.linkedAzureDevOpsPR, + linkedGiteaPR: review.linkedGiteaPR, + branch: review.branch, + fetchHostedReviewForBranch: foundation.fetchHostedReviewForBranch, + shouldRefreshHostedReview: false, + newCardStyle: true, + hoverDetailsOpen: true, + showIssue: true, + issueCacheKey: review.issueCacheKey, + fetchIssue: foundation.fetchIssue, + showLinearIssue: true, + fetchLinearIssue: foundation.fetchLinearIssue, + linearSourceContext: review.linearSourceContext, + linearWorkspaceId: review.linearWorkspaceId + }) + const rows = getWorkspaceLinkedItemRows( + worktree, + { + review: review.prDisplay, + issue: linked.issueDisplay, + linearIssue: linked.linearIssue, + jiraIssue: linked.jiraIssueDisplay + }, + // Why no per-item details: their lookup routes by the focused server, which this page must not. + undefined + ) + // Why: the card also shows the review found from the branch, which no saved link may name. + const branchReview = review.prDisplay + if ( + !branchReview?.url || + branchReview.provider === 'unsupported' || + rows.some( + (row) => + row.item.type !== 'issue' && + row.item.provider === branchReview.provider && + row.item.number === branchReview.number + ) + ) { + return rows + } + const branchRow: WorkspaceLinkedItemRow = { + item: { + provider: branchReview.provider, + type: branchReview.provider === 'gitlab' ? 'mr' : 'pr', + number: branchReview.number, + title: branchReview.title, + url: branchReview.url + }, + key: `branch-review:${branchReview.provider}:${branchReview.number}`, + url: branchReview.url, + detail: undefined, + live: branchReview, + liveTask: null + } + return [branchRow, ...rows] +} diff --git a/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-servers.ts b/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-servers.ts new file mode 100644 index 00000000000..1bed65aefaf --- /dev/null +++ b/src/renderer/src/components/browser-pane/new-tab/use-browser-new-tab-servers.ts @@ -0,0 +1,128 @@ +import { useEffect, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' +import { workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' +import { refreshWorkspacePortScanForTarget } from '@/lib/workspace-port-scan-refresh' +import { probeWorkspacePortPageForTarget } from '@/lib/workspace-port-scan-client' +import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { + WorkspacePort, + WorkspacePortPageProbeResult +} from '../../../../../shared/workspace-ports' + +export type BrowserNewTabServer = { + port: WorkspacePort + title: string | null + faviconDataUrl: string | null +} + +// Why a short cache: every new tab asks again, and a dev server's page rarely changes in seconds. +const PROBE_TTL_MS = 15_000 +const probeCache = new Map< + string, + { probedAt: number; result: Promise } +>() + +function probeKey(target: RuntimeClientTarget, port: WorkspacePort): string { + return `${workspacePortScanKeyForTarget(target)}|${port.connectHost}|${port.port}|${port.pid ?? ''}` +} + +function probePortPage( + target: RuntimeClientTarget, + port: WorkspacePort +): Promise { + const key = probeKey(target, port) + const cached = probeCache.get(key) + if (cached && Date.now() - cached.probedAt < PROBE_TTL_MS) { + return cached.result + } + const advertisedHttps = port.kind === 'workspace' && port.advertisedUrl?.startsWith('https:') + const result = probeWorkspacePortPageForTarget(target, { + host: port.connectHost, + port: port.port, + protocol: advertisedHttps || port.protocol === 'https' ? 'https' : 'http' + }).catch((): WorkspacePortPageProbeResult => ({ kind: 'not-page' })) + probeCache.set(key, { probedAt: Date.now(), result }) + return result +} + +export type BrowserNewTabServers = { + /** This worktree's servers. */ + own: BrowserNewTabServer[] + /** Other worktrees' servers on the same host, so this tab reaches them at the same address. */ + others: BrowserNewTabServer[] +} + +/** Listeners on this worktree's host that answer with a web page, freshly scanned on open. */ +export function useBrowserNewTabServers( + worktreeId: string, + runtimeTarget: RuntimeClientTarget | null +): BrowserNewTabServers { + const hostScan = useAppStore((s) => + runtimeTarget ? s.workspacePortScansByKey[workspacePortScanKeyForTarget(runtimeTarget)] : null + ) + const ports = useMemo( + () => [...getWorkspacePortsByWorktreeId(hostScan).values()].flat(), + [hostScan] + ) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) + const [probes, setProbes] = useState>( + () => new Map() + ) + + useEffect(() => { + if (!runtimeTarget) { + return + } + // Why: the background poll runs every 30s; a server started moments ago should be here now. + void refreshWorkspacePortScanForTarget({ + runtimeTarget, + replaceWorkspacePortScans, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey + }) + }, [runtimeTarget, replaceWorkspacePortScans]) + + useEffect(() => { + if (!runtimeTarget) { + return + } + let cancelled = false + for (const port of ports) { + const key = probeKey(runtimeTarget, port) + void probePortPage(runtimeTarget, port).then((result) => { + if (!cancelled) { + setProbes((previous) => + previous.get(key) === result ? previous : new Map(previous).set(key, result) + ) + } + }) + } + return () => { + cancelled = true + } + }, [ports, runtimeTarget]) + + const servers = runtimeTarget + ? ports.flatMap((port): BrowserNewTabServer[] => { + const probe = probes.get(probeKey(runtimeTarget, port)) + if (probe === undefined) { + return [] + } + if (probe === null) { + // An older host cannot say; a URL its terminal printed is the best evidence of a page. + return port.kind === 'workspace' && port.advertisedUrl + ? [{ port, title: null, faviconDataUrl: null }] + : [] + } + return probe.kind === 'page' + ? [{ port, title: probe.title, faviconDataUrl: probe.faviconDataUrl }] + : [] + }) + : [] + const ownedHere = (server: BrowserNewTabServer): boolean => + server.port.kind === 'workspace' && server.port.owner.worktreeId === worktreeId + return { + own: servers.filter(ownedHere), + others: servers.filter((server) => !ownedHere(server)) + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx index 62689bc7c3a..11d250801c0 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' @@ -226,7 +226,7 @@ describe('RemoteBrowserPagePane address bar parity', () => { expect(input.value).toBe('') }) - it('offers the shared URL history as suggestions once the bar has focus', () => { + it('offers the shared URL history once the user types, not on a blank tab’s focus', () => { useAppStore.setState({ browserUrlHistory: [ { @@ -243,6 +243,9 @@ describe('RemoteBrowserPagePane address bar parity', () => { renderPane() + // The New Tab page under the bar already lists history, so focus alone opens nothing. + expect(screen.queryByText('Google Maps')).toBeNull() + fireEvent.change(addressBar(), { target: { value: 'maps' } }) expect(screen.getByText('Google Maps')).toBeTruthy() }) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index ba11d900223..29c1c3d1f1c 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -422,6 +422,7 @@ export function RemoteBrowserPagePane({ runtimeTarget={runtimeTarget} onReload={() => void runRemoteNavigation('browser.reload')} onGoto={(url) => void runRemoteNavigation('browser.goto', url)} + onNavigateToUrl={navigateToUrl} onReconnect={reconnectRemoteStream} handleRemotePointerDown={handleRemotePointerDown} handleRemotePointerUp={handleRemotePointerUp} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx index f4d6a3f966e..1c2472abeb2 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { Globe, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' -import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { isBlankBrowserUrl } from '@/lib/browser-palette-search' import type { BrowserCertificateFailure, BrowserCertificateProceedResult, @@ -20,6 +20,7 @@ import type { MarkupModeController } from '../annotate/useMarkupMode' import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay' import { ReopenBrowserPageOnServerButton } from '../ReopenBrowserPageOnServerButton' import { toDisplayUrl } from '../describe-page/browser-page-url-display' +import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage' import { canReconnectRemoteBrowserStream, type RemoteBrowserStreamStatus @@ -46,6 +47,7 @@ export function RemoteBrowserPageViewport({ runtimeTarget, onReload, onGoto, + onNavigateToUrl, onReconnect, handleRemotePointerDown, handleRemotePointerUp, @@ -70,6 +72,8 @@ export function RemoteBrowserPageViewport({ runtimeTarget: () => RemoteBrowserRuntimeTarget | null onReload: () => void onGoto: (url: string) => void + /** The address bar's submit path, which also parks a URL typed before the host page exists. */ + onNavigateToUrl: (url: string) => void onReconnect: () => void handleRemotePointerDown: (event: React.PointerEvent) => void handleRemotePointerUp: (event: React.PointerEvent) => void @@ -80,9 +84,7 @@ export function RemoteBrowserPageViewport({ const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) const showRemoteFailureOverlay = - Boolean(browserTab.loadError) && - remoteFailureUrl !== 'about:blank' && - remoteFailureUrl !== ORCA_BROWSER_BLANK_URL + Boolean(browserTab.loadError) && !isBlankBrowserUrl(remoteFailureUrl) return (
)} + {isBlankBrowserUrl(browserTab.url) ? ( + + ) : null} {showRemoteFailureOverlay && browserTab.loadError ? ( item.type !== 'issue') + rows: rows.filter((row) => row.item.type !== 'issue') }, { label: translate('workspace.links.tasks', 'Issues & tasks'), - items: items.filter((item) => item.type === 'issue') + rows: rows.filter((row) => row.item.type === 'issue') } - ].filter((group) => group.items.length) + ].filter((group) => group.rows.length) const open = (item: WorkspaceAttachment, event: React.MouseEvent): void => { event.stopPropagation() if (item.url) { @@ -87,24 +85,12 @@ export function WorktreeCardLinkedItemsSection({
} - label={`${group.label} · ${group.items.length}`} + label={`${group.label} · ${group.rows.length}`} />
- {group.items.map((item) => { - const key = getWorkspaceAttachmentKey(item) + {group.rows.map(({ item, key, url, detail, live }) => { const label = workspaceAttachmentLabel(item) const source = getWorkspaceAttachmentSourceLabel(item) - const active = key === activeKey - const detail = referenceDetails?.[referenceKeys[items.indexOf(item)]] - const url = item.url || detail?.url - const live = - detail?.review ?? - (active && - review?.provider === item.provider && - review.number === item.number && - (!item.url || review.url === item.url) - ? review - : null) return (
event.stopPropagation()} > - {items.length + {rows.length ? translate('workspace.links.manage', 'Manage links…') : translate('workspace.links.addReference', 'Add reference')} diff --git a/src/renderer/src/components/sidebar/workspace-hover-attachments.ts b/src/renderer/src/components/sidebar/workspace-hover-attachments.ts index 96ddc18991d..cbc22231300 100644 --- a/src/renderer/src/components/sidebar/workspace-hover-attachments.ts +++ b/src/renderer/src/components/sidebar/workspace-hover-attachments.ts @@ -9,6 +9,10 @@ import type { WorktreeCardJiraIssueDisplay } from './worktree-card-meta-types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import type { + WorkspaceReferenceDetails, + WorkspaceReferenceDetailsMap +} from './workspace-reference-details' type LiveDetails = { issue?: WorktreeCardIssueDisplay | null @@ -24,8 +28,11 @@ export function getWorkspaceHoverAttachments( items: WorkspaceAttachment[] activeKey: string | null referenceKeys: string[] + /** Per item: whether it is the card's own review, issue or task, enriched from live details. */ + liveMatched: boolean[] } { const items = getWorkspaceAttachments(workspace) + const liveMatched: boolean[] = [] const matches = (item: WorkspaceAttachment): boolean => { if (item.provider === 'github' && item.type === 'issue') { return item.number === details.issue?.number @@ -62,6 +69,7 @@ export function getWorkspaceHoverAttachments( matches(candidate) && (!candidate.url || candidate.url === live.url) ).length === 1 + liveMatched.push(Boolean(sameReference)) return sameReference ? { ...item, @@ -88,9 +96,54 @@ export function getWorkspaceHoverAttachments( return { items: enriched, referenceKeys: items.map(getWorkspaceAttachmentKey), + liveMatched, activeKey: parseWorkspaceKey(workspace.id)?.type === 'folder' || !activeItem ? null : getWorkspaceAttachmentKey(activeItem) } } + +export type WorkspaceLinkedItemRow = { + item: WorkspaceAttachment + key: string + url: string | undefined + detail: WorkspaceReferenceDetails | undefined + /** The review whose live state and checks this row shows, when one applies to it. */ + live: WorktreeCardPrDisplay | null + /** The card's own issue or task this row is, with the state the card already loaded. */ + liveTask: WorktreeCardIssueDisplay | WorktreeCardLinearIssueDisplay | null +} + +/** One row per linked item, with the URL to open and the live review state the row shows. */ +export function getWorkspaceLinkedItemRows( + workspace: Worktree, + details: LiveDetails, + referenceDetails: WorkspaceReferenceDetailsMap | undefined +): WorkspaceLinkedItemRow[] { + const { items, activeKey, referenceKeys, liveMatched } = getWorkspaceHoverAttachments( + workspace, + details + ) + const review = details.review + return items.map((item, index) => { + const key = getWorkspaceAttachmentKey(item) + const detail = referenceDetails?.[referenceKeys[index]] + const live = + detail?.review ?? + (key === activeKey && + review?.provider === item.provider && + review.number === item.number && + (!item.url || review.url === item.url) + ? review + : null) + const liveTask = !liveMatched[index] + ? null + : item.provider === 'github' && item.type === 'issue' + ? (details.issue ?? null) + : item.provider === 'linear' + ? (details.linearIssue ?? null) + : null + return { item, key, url: item.url || detail?.url, detail, live, liveTask } + }) +} diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx index b56f5432cc8..3b091009a25 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.tsx @@ -3,11 +3,7 @@ import { Plug, ChevronDown, ChevronRight, LoaderCircle } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' -import { - publishWorkspacePortScanForHost, - scanWorkspacePortsForTarget, - workspacePortScanKeyForTarget -} from '@/lib/workspace-port-actions' +import { refreshWorkspacePortScanForTarget } from '@/lib/workspace-port-scan-refresh' import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { getUnavailableWorkspacePortHosts, @@ -19,7 +15,6 @@ import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' import { PortRow, WorkspaceGroupRows } from './ports-status-popover-rows' import { translate } from '@/i18n/i18n' -import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' type PortsStatusSegmentProps = { compact?: boolean @@ -38,7 +33,6 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React const [open, setOpen] = useState(false) const [externalOpen, setExternalOpen] = useState(false) const runtimeTarget = useWorktreeRuntimeTarget(activeWorktreeId) - const scanKey = runtimeTarget ? workspacePortScanKeyForTarget(runtimeTarget) : null const workspaceGroups = useMemo(() => getWorkspacePortGroups(scan), [scan]) const externalPorts = useMemo(() => getExternalWorkspacePorts(scan), [scan]) @@ -87,36 +81,18 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React return } recordFeatureInteraction('ports') - if (!runtimeTarget || !scanKey) { + if (!runtimeTarget) { return } // Why: the 30s background poll is intentionally quiet; opening the // popover should still collapse that stale window without flashing icons. - const publish = (result: WorkspacePortScanResult): void => { - publishWorkspacePortScanForHost({ - scanKey, - scan: result, - replaceWorkspacePortScans, - getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey - }) - } - void scanWorkspacePortsForTarget(runtimeTarget) - .then(publish) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - // Why: one dropped scan must not clear the host's last-good ports the - // way the background poll's debounce does not; the failure is still - // recorded so the host is named by the unavailable notice below. - const previous = useAppStore.getState().workspacePortScansByKey[scanKey] - publish({ - platform: previous?.platform ?? 'unknown', - scannedAt: Date.now(), - ports: previous?.ports ?? [], - unavailableReason: message || 'Workspace port scan failed.' - }) - }) + void refreshWorkspacePortScanForTarget({ + runtimeTarget, + replaceWorkspacePortScans, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey + }) }, - [recordFeatureInteraction, runtimeTarget, scanKey, replaceWorkspacePortScans] + [recordFeatureInteraction, runtimeTarget, replaceWorkspacePortScans] ) return ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 30b4434f98a..8cc6cb4e4b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -157,6 +157,27 @@ }, "request": { "pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs." + }, + "newTab": { + "emptyHint": "Search or enter an address above. Sites you visit, dev servers running in this workspace, and its PR and issue will show up here.", + "openServerFailed": "Couldn’t open {{address}}", + "removedFromHistory": "Removed “{{title}}” from history", + "removeSuggestion": "Remove from history", + "state": { + "closed": "Closed", + "draft": "Draft", + "merged": "Merged", + "open": "Open" + }, + "suggested": "Suggested", + "thisWorkspace": "This workspace", + "undo": "Undo", + "checks": { + "failing": "Checks failing", + "passing": "Checks passing", + "pending": "Checks pending" + }, + "runningElsewhere": "{{count}} running in other workspaces" } }, "editor": { @@ -16976,7 +16997,6 @@ "95af781091": "Send feedback to an agent", "ac39b9366b": "Send", "a3508d7e6e": "{{value0}} annotation{{value1}} ready. Select another element or copy all feedback.", - "f796c774a4": "Type a URL above to start browsing.", "366bf5d62c": "New Tab", "1c78adc73d": "Open Externally", "da68d35f7b": "Open failed page in default browser", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b40c89911c3..4e35112c6e4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16656,7 +16656,6 @@ "95af781091": "Enviar comentarios a un agente", "ac39b9366b": "Enviar", "a3508d7e6e": "{{value0}} anotación{{value1}} listo. Seleccione otro elemento o copie todos los comentarios.", - "f796c774a4": "Escriba una URL arriba para comenzar a navegar.", "366bf5d62c": "Nueva pestaña", "1c78adc73d": "Abrir externamente", "da68d35f7b": "Abrir página fallida en el navegador predeterminado", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f2ad7a7cdc2..5921272067e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16769,7 +16769,6 @@ "95af781091": "Envoyer un retour à un agent", "ac39b9366b": "Envoyer", "a3508d7e6e": "{{value0}} annotation{{value1}} prête. Sélectionnez un autre élément ou copiez tous les retours.", - "f796c774a4": "Saisissez une URL ci-dessus pour commencer à naviguer.", "366bf5d62c": "Nouvel onglet", "1c78adc73d": "Ouvrir en externe", "da68d35f7b": "Ouvrir la page en erreur dans le navigateur par défaut", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b8a52a77e4e..1ac32cdc8f1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16661,7 +16661,6 @@ "95af781091": "Agent にフィードバックを送信する", "ac39b9366b": "送信", "a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。", - "f796c774a4": "上に URL を入力して閲覧を開始します。", "366bf5d62c": "新規タブ", "1c78adc73d": "外部で開く", "da68d35f7b": "失敗したページをデフォルトのブラウザで開く", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2fb09657776..b15ba19c39f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16661,7 +16661,6 @@ "95af781091": "agent에게 피드백 보내기", "ac39b9366b": "보내기", "a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.", - "f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.", "366bf5d62c": "새 탭", "1c78adc73d": "외부에서 열기", "da68d35f7b": "기본 브라우저에서 실패한 페이지 열기", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 7165d202856..0c8d3a52d1d 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -16337,7 +16337,6 @@ "95af781091": "Enviar feedback para um agente", "ac39b9366b": "Enviar", "a3508d7e6e": "{{value0}} anotação{{value1}} pronta. Selecione outro elemento ou copie todo o feedback.", - "f796c774a4": "Digite uma URL acima para começar a navegar.", "366bf5d62c": "Nova Aba", "1c78adc73d": "Abrir Externamente", "da68d35f7b": "Abrir a página com falha no navegador padrão", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7b329053098..8d631656c7a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16661,7 +16661,6 @@ "95af781091": "向智能体发送反馈", "ac39b9366b": "发送", "a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。", - "f796c774a4": "在上面输入 URL 即可开始浏览。", "366bf5d62c": "新标签", "1c78adc73d": "在外部打开", "da68d35f7b": "在默认浏览器中打开失败页面", diff --git a/src/renderer/src/lib/browser-history-match.test.ts b/src/renderer/src/lib/browser-history-match.test.ts index 6901e2ab628..b64d8d17dc3 100644 --- a/src/renderer/src/lib/browser-history-match.test.ts +++ b/src/renderer/src/lib/browser-history-match.test.ts @@ -3,6 +3,7 @@ import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-type import { matchBrowserHistory, prepareBrowserHistoryEntries, + rankBrowserTopSites, type BrowserHistoryMatchTier } from './browser-history-match' @@ -212,3 +213,26 @@ describe('browser history match', () => { } }) }) + +describe('rankBrowserTopSites', () => { + it('ranks pages by visits and recency, leaving out dev servers and non-web pages', () => { + const ranked = rankBrowserTopSites({ + prepared: prepareBrowserHistoryEntries([ + entry({ url: 'https://react.dev/', visitCount: 3, lastVisitedAt: NOW - 48 * HOUR }), + entry({ url: 'https://github.com/acme/orca', visitCount: 2, lastVisitedAt: NOW }), + entry({ url: 'http://localhost:5173/', visitCount: 40 }), + entry({ url: 'http://web.orca.localhost:3000/', visitCount: 40 }), + entry({ url: 'file:///tmp/notes.html', visitCount: 40 }), + entry({ url: 'https://example.com/', visitCount: 1, lastVisitedAt: NOW - 48 * HOUR }) + ]), + limit: 2, + now: NOW + }) + + // github: 2 visits + a fresh-visit bonus beats react's 3 stale visits. + expect(ranked.map((page) => page.url)).toEqual([ + 'https://github.com/acme/orca', + 'https://react.dev/' + ]) + }) +}) diff --git a/src/renderer/src/lib/browser-history-match.ts b/src/renderer/src/lib/browser-history-match.ts index 9c701a26eaf..a0fa11665b6 100644 --- a/src/renderer/src/lib/browser-history-match.ts +++ b/src/renderer/src/lib/browser-history-match.ts @@ -2,6 +2,10 @@ // bar and the new-tab omnibox. Pure: no store, no React. import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types' +import { + LOOPBACK_LOCALHOST_HOSTS, + normalizeLocalhostHostname +} from '../../../shared/localhost-worktree-labels' import { formatBrowserPaletteUrl } from './browser-palette-search' /** Compared before frecency, so "the site you meant" always beats "appears somewhere". */ @@ -73,6 +77,15 @@ export function prepareBrowserHistoryEntries( return prepared } +function frecencyScore(prepared: PreparedBrowserHistoryEntry, now: number): number { + const ageHours = (now - prepared.entry.lastVisitedAt) / (1000 * 60 * 60) + // Clamped both ends: a future lastVisitedAt must not buy more than a fresh visit. + return ( + prepared.frecencyBase + + Math.min(RECENCY_BONUS_HOURS, Math.max(0, RECENCY_BONUS_HOURS - ageHours)) + ) +} + function matchTier( prepared: PreparedBrowserHistoryEntry, lowerQuery: string @@ -118,15 +131,7 @@ export function matchBrowserHistory({ if (tier === null || (tier === 'url-tail' && !includeUrlTail)) { continue } - const ageHours = (now - candidate.entry.lastVisitedAt) / (1000 * 60 * 60) - matches.push({ - entry: candidate.entry, - tier, - // Clamped both ends: a future lastVisitedAt must not buy more than a fresh visit. - score: - candidate.frecencyBase + - Math.min(RECENCY_BONUS_HOURS, Math.max(0, RECENCY_BONUS_HOURS - ageHours)) - }) + matches.push({ entry: candidate.entry, tier, score: frecencyScore(candidate, now) }) } if (matches.length === 0) { return NO_MATCHES @@ -142,3 +147,40 @@ export function matchBrowserHistory({ ) return matches.slice(0, limit) } + +// Why loopback is out: dev servers come and go, so the new-tab page lists the live ones instead. +function isTopSiteCandidate(url: string): boolean { + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false + } + const hostname = normalizeLocalhostHostname(parsed.hostname) + return !LOOPBACK_LOCALHOST_HOSTS.has(hostname) && !hostname.endsWith('.localhost') + } catch { + return false + } +} + +/** New-tab tiles, one per page as Chrome does, so removing a tile removes exactly that page. */ +export function rankBrowserTopSites({ + limit, + now = Date.now(), + prepared +}: { + limit: number + now?: number + prepared: readonly PreparedBrowserHistoryEntry[] +}): readonly BrowserHistoryEntry[] { + return prepared + .filter((candidate) => isTopSiteCandidate(candidate.entry.url)) + .map((candidate) => ({ entry: candidate.entry, score: frecencyScore(candidate, now) })) + .sort( + (a, b) => + b.score - a.score || + b.entry.lastVisitedAt - a.entry.lastVisitedAt || + a.entry.normalizedUrl.localeCompare(b.entry.normalizedUrl) + ) + .slice(0, limit) + .map((ranked) => ranked.entry) +} diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts index 6cfedb8c173..d32e67a3008 100644 --- a/src/renderer/src/lib/workspace-port-actions.ts +++ b/src/renderer/src/lib/workspace-port-actions.ts @@ -97,6 +97,23 @@ export function goToWorkspacePortOwner(port: WorkspacePort): boolean { return Boolean(worktreeId && activateAndRevealWorktree(worktreeId)) } +/** The URL a port opens at: its labelled localhost name when one is set up locally, else its raw URL. */ +export async function resolveWorkspacePortBrowserUrl(args: { + port: WorkspacePort + runtimeTarget: RuntimeClientTarget + localhostLabelRoute?: LocalhostWorktreeLabelRoute | null +}): Promise { + const rawUrl = browserUrlForPort(args.port) + if (args.runtimeTarget.kind !== 'local' || !args.localhostLabelRoute) { + return rawUrl + } + try { + return (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url + } catch { + return rawUrl + } +} + export async function openWorkspacePortInBrowser(args: { port: WorkspacePort activeWorktreeId?: string | null @@ -109,15 +126,11 @@ export async function openWorkspacePortInBrowser(args: { if (!args.runtimeTarget) { return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON } } - const rawUrl = browserUrlForPort(args.port) - let url = rawUrl - if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) { - try { - url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url - } catch { - url = rawUrl - } - } + const url = await resolveWorkspacePortBrowserUrl({ + port: args.port, + runtimeTarget: args.runtimeTarget, + localhostLabelRoute: args.localhostLabelRoute + }) if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') { try { await window.api.shell.openUrl(url) diff --git a/src/renderer/src/lib/workspace-port-scan-client.ts b/src/renderer/src/lib/workspace-port-scan-client.ts index 0c652d279a6..c131ee848e7 100644 --- a/src/renderer/src/lib/workspace-port-scan-client.ts +++ b/src/renderer/src/lib/workspace-port-scan-client.ts @@ -8,6 +8,8 @@ import type { WorkspacePort, WorkspacePortHostScanResult, WorkspacePortKillResult, + WorkspacePortPageProbeRequest, + WorkspacePortPageProbeResult, WorkspacePortScanResult } from '../../../shared/workspace-ports' import { normalizeExecutionHostId } from '../../../shared/execution-host' @@ -213,3 +215,29 @@ export async function killWorkspacePortOnExecutionHost( throw error } } + +/** + * Whether a scanned port serves a web page, asked of the host the port is on. `null` when that + * host's Orca predates the probe, so callers fall back to what the scan alone knows. + */ +export async function probeWorkspacePortPageForTarget( + target: RuntimeClientTarget, + request: WorkspacePortPageProbeRequest +): Promise { + if (target.kind === 'local') { + return window.api.workspacePorts.probePage(request) + } + try { + return await callRuntimeRpc( + target, + 'workspacePorts.probePage', + request, + { timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS } + ) + } catch (error) { + if (isMethodNotFound(error)) { + return null + } + throw error + } +} diff --git a/src/renderer/src/lib/workspace-port-scan-refresh.ts b/src/renderer/src/lib/workspace-port-scan-refresh.ts new file mode 100644 index 00000000000..e7a49fb2414 --- /dev/null +++ b/src/renderer/src/lib/workspace-port-scan-refresh.ts @@ -0,0 +1,32 @@ +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' +import { + publishWorkspacePortScanForHost, + scanWorkspacePortsForTarget, + workspacePortScanKeyForTarget, + type WorkspacePortScanPublisher +} from './workspace-port-actions' + +/** Re-scans one host now. A failed scan keeps the host's last-good ports and records the failure. */ +export async function refreshWorkspacePortScanForTarget( + args: WorkspacePortScanPublisher & { runtimeTarget: RuntimeClientTarget } +): Promise { + const scanKey = workspacePortScanKeyForTarget(args.runtimeTarget) + const publish = (scan: WorkspacePortScanResult): void => { + publishWorkspacePortScanForHost({ ...args, scanKey, scan }) + } + try { + publish(await scanWorkspacePortsForTarget(args.runtimeTarget)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Why: one dropped scan must not clear the host's last-good ports the way the background + // poll's debounce does not; the failure is still recorded so the host is named as unavailable. + const previous = args.getWorkspacePortScansByKey()[scanKey] + publish({ + platform: previous?.platform ?? 'unknown', + scannedAt: Date.now(), + ports: previous?.ports ?? [], + unavailableReason: message || 'Workspace port scan failed.' + }) + } +} diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index fd090a9a0ca..f3c6f19f6e4 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -311,6 +311,25 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeNull() }) + it('puts a removed history page back as it was on Undo, unless a revisit already re-created it', () => { + const store = createTestStore() + store.getState().addBrowserHistoryEntry('https://example.com', 'Example') + store.getState().addBrowserHistoryEntry('https://example.com', 'Example') + const [visited] = store.getState().browserUrlHistory + + const removed = store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl) + expect(removed).toBe(visited) + expect(store.getState().browserUrlHistory).toEqual([]) + store.getState().restoreBrowserHistoryEntry(removed!) + expect(store.getState().browserUrlHistory).toEqual([visited]) + + store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl) + store.getState().addBrowserHistoryEntry('https://example.com', 'Example') + const revisited = store.getState().browserUrlHistory + store.getState().restoreBrowserHistoryEntry(removed!) + expect(store.getState().browserUrlHistory).toBe(revisited) + }) + it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts index fce9d58cae1..9e9880b5695 100644 --- a/src/renderer/src/store/slices/browser/browser-history-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts @@ -17,10 +17,14 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' export function createBrowserHistoryActions( set: BrowserSliceSet, - _get: BrowserSliceGet + get: BrowserSliceGet ): Pick< BrowserSlice, - 'recordWorkspaceDocVisit' | 'addBrowserHistoryEntry' | 'clearBrowserHistory' + | 'recordWorkspaceDocVisit' + | 'addBrowserHistoryEntry' + | 'removeBrowserHistoryEntry' + | 'restoreBrowserHistoryEntry' + | 'clearBrowserHistory' > { return { recordWorkspaceDocVisit: (docLocation, title, options) => { @@ -110,6 +114,26 @@ export function createBrowserHistoryActions( }) }, + removeBrowserHistoryEntry: (normalizedUrl) => { + const removed = + get().browserUrlHistory.find((entry) => entry.normalizedUrl === normalizedUrl) ?? null + if (removed) { + set((s) => ({ + browserUrlHistory: s.browserUrlHistory.filter((entry) => entry !== removed) + })) + } + return removed + }, + + restoreBrowserHistoryEntry: (entry) => { + set((s) => + // A revisit between remove and Undo already re-created the row; keep the live one. + s.browserUrlHistory.some((existing) => existing.normalizedUrl === entry.normalizedUrl) + ? s + : { browserUrlHistory: [entry, ...s.browserUrlHistory] } + ) + }, + // One clear for both sources: the dropdown presents them as one history. clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) } diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 2d9fdd97ad4..e51e793d28e 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -260,6 +260,9 @@ export type BrowserSlice = { clearDefaultSessionCookies: () => Promise browserUrlHistory: BrowserHistoryEntry[] addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string | null) => void + /** Returns the removed entry so an Undo can put it back with its visit count. */ + removeBrowserHistoryEntry: (normalizedUrl: string) => BrowserHistoryEntry | null + restoreBrowserHistoryEntry: (entry: BrowserHistoryEntry) => void workspaceDocHistory: WorkspaceDocHistoryEntry[] /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ recordWorkspaceDocVisit: ( diff --git a/src/renderer/src/web/preload-api/web-workspace-ports-api.ts b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts index dcc18462a94..a0b5d7c7872 100644 --- a/src/renderer/src/web/preload-api/web-workspace-ports-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts @@ -17,6 +17,7 @@ export function createWebWorkspacePortsApi(): Partial { ok: false, reason: 'Workspace port management is unavailable for browser-local workspaces.' }), + probePage: () => Promise.resolve({ kind: 'not-page' }), onAdvertisedUrlChanged: () => noopUnsubscribe } } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index bd7261529e8..311d0ae208a 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -624,6 +624,7 @@ import { import { WorkspacePortKillHostParams, WorkspacePortKillParams, + WorkspacePortProbePageParams, WorkspacePortScanHostParams, WorkspacePortScanParams } from './workspace-ports-params' @@ -1403,6 +1404,7 @@ export const RPC_PARAMS_BY_METHOD = { 'updater.install': null, 'workspacePorts.kill': WorkspacePortKillParams, 'workspacePorts.killHost': WorkspacePortKillHostParams, + 'workspacePorts.probePage': WorkspacePortProbePageParams, 'workspacePorts.scan': WorkspacePortScanParams, 'workspacePorts.scanHost': WorkspacePortScanHostParams, 'worktree.activate': WorktreeActivate, diff --git a/src/shared/rpc-contract/workspace-ports-params.ts b/src/shared/rpc-contract/workspace-ports-params.ts index 1583505af5c..604962ab6cd 100644 --- a/src/shared/rpc-contract/workspace-ports-params.ts +++ b/src/shared/rpc-contract/workspace-ports-params.ts @@ -11,6 +11,12 @@ export const WorkspacePortKillParams = z.object({ port: requiredNumber('Missing port') }) +export const WorkspacePortProbePageParams = z.object({ + host: requiredString('Missing host'), + port: requiredNumber('Missing port'), + protocol: z.enum(['http', 'https']) +}) + export const WorkspacePortScanHostParams = z.object({ worktree: requiredString('Missing worktree selector') }) diff --git a/src/shared/workspace-ports.ts b/src/shared/workspace-ports.ts index b474003c7f6..7519e9a319b 100644 --- a/src/shared/workspace-ports.ts +++ b/src/shared/workspace-ports.ts @@ -54,6 +54,17 @@ export type WorkspacePortAdvertisedUrlChangedEvent = { port: number } +/** Asks whether a scanned listener serves a web page. Loopback only; the scan supplies host and port. */ +export type WorkspacePortPageProbeRequest = { + host: string + port: number + protocol: 'http' | 'https' +} + +export type WorkspacePortPageProbeResult = + | { kind: 'page'; title: string | null; faviconDataUrl: string | null } + | { kind: 'not-page' } + export type WorkspacePortKillRequest = { repoId?: string pid: number