mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
feat(browser): new tab page with a workspace card and suggested sites (#31656)
This commit is contained in:
@@ -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<AdvertisedUrlWatcher, 'onDidChange'>
|
||||
@@ -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<WorkspacePortScanResult> => {
|
||||
@@ -76,6 +80,14 @@ export function registerWorkspacePortHandlers(
|
||||
return killWorkspacePort(worktrees, args)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspacePorts:probePage',
|
||||
async (_event, rawArgs?: unknown): Promise<WorkspacePortPageProbeResult> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"/>')
|
||||
const servers: http.Server[] = []
|
||||
|
||||
async function serve(handler: http.RequestListener): Promise<number> {
|
||||
const server = http.createServer(handler)
|
||||
servers.push(server)
|
||||
await new Promise<void>((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(
|
||||
'<html><head><title>\n Vite & React </title><link rel="shortcut icon" href="/assets/icon.svg"></head></html>'
|
||||
)
|
||||
})
|
||||
|
||||
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('<title>Sign in</title>')
|
||||
})
|
||||
|
||||
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('<title>x</title>')
|
||||
})
|
||||
|
||||
await expect(
|
||||
probeWorkspacePortPage({ host: 'example.com', port, protocol: 'http' })
|
||||
).resolves.toEqual({ kind: 'not-page' })
|
||||
expect(requested).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspacePortPageProbeResult> {
|
||||
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<ProbeResponse | null> {
|
||||
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 = /<title\b[^>]*>([\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<string, string> = { 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(/<link\b[^>]*>/gi) ?? []) {
|
||||
const attributes = new Map<string, string>()
|
||||
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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<WorkspacePortScanResult>
|
||||
kill: (args: WorkspacePortKillRequest) => Promise<WorkspacePortKillResult>
|
||||
probePage: (args: WorkspacePortPageProbeRequest) => Promise<WorkspacePortPageProbeResult>
|
||||
onAdvertisedUrlChanged: (
|
||||
callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void
|
||||
) => () => void
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ? (
|
||||
<BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={navigateToUrl} serversElsewhere />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-21
@@ -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 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
|
||||
<div className="flex flex-col items-center px-8 py-8 text-center opacity-70">
|
||||
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
|
||||
<Globe className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground/85">
|
||||
{translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.browser.pane.BrowserPane.f796c774a4',
|
||||
'Type a URL above to start browsing.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isBlankTab ? <BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={navigateToUrl} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
@@ -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 `<webview>` belongs to unless the host carries the page id. The paired restart e2e
|
||||
// binds its marker read to exactly this stamp.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,5 +18,6 @@ export type BrowserClientRetainedRendererPage = {
|
||||
onReady: EventListener
|
||||
onDestroyed: EventListener
|
||||
onRendererGone: EventListener
|
||||
onLoadCommit: (event: Electron.LoadCommitEvent) => void
|
||||
releaseDragPassthroughSurface: () => void
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<BrowserNewTabWorktreePage
|
||||
worktree={worktree}
|
||||
repo={repo}
|
||||
servers={servers}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
) : (
|
||||
<BrowserNewTabPageLayout
|
||||
linkedItems={NO_LINKED_ITEMS}
|
||||
servers={servers}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<BrowserNewTabPageLayout linkedItems={linkedItems} servers={servers} onOpenUrl={onOpenUrl} />
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<BrowserNewTabScroller>
|
||||
<div className="flex flex-col items-center gap-2.5 px-4 pt-16 text-center">
|
||||
<div className="mb-1.5 grid size-13 place-items-center rounded-full border border-border text-muted-foreground">
|
||||
<Globe className="size-5" />
|
||||
</div>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')}
|
||||
</p>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</BrowserNewTabScroller>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserNewTabScroller>
|
||||
{hasWorkspaceRows ? (
|
||||
<BrowserNewTabSection label={translate('browser.newTab.thisWorkspace', 'This workspace')}>
|
||||
<BrowserNewTabWorkspaceCard
|
||||
servers={servers}
|
||||
linkedItems={openableLinkedItems}
|
||||
runtimeTarget={runtimeTarget}
|
||||
openServersInPlace={openInPlace}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
</BrowserNewTabSection>
|
||||
) : null}
|
||||
{suggested.length > 0 ? (
|
||||
<BrowserNewTabSection label={translate('browser.newTab.suggested', 'Suggested')}>
|
||||
<BrowserNewTabTileGrid>
|
||||
{suggested.map((entry) => (
|
||||
<BrowserNewTabTile
|
||||
key={entry.normalizedUrl}
|
||||
icon={
|
||||
<BrowserFavicon
|
||||
faviconUrl={entry.faviconUrl}
|
||||
className="size-6"
|
||||
fallbackClassName="size-5 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
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')}
|
||||
/>
|
||||
))}
|
||||
</BrowserNewTabTileGrid>
|
||||
</BrowserNewTabSection>
|
||||
) : null}
|
||||
</BrowserNewTabScroller>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserNewTabScroller({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<div className="scrollbar-sleek @container absolute inset-0 z-10 overflow-y-auto bg-background">
|
||||
<div className="mx-auto flex max-w-[680px] flex-col gap-8 px-3 pt-14 pb-12 @xs:px-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserNewTabSection({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="px-2 text-2xs font-semibold tracking-[0.05em] text-muted-foreground uppercase">
|
||||
{label}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.25rem,1fr))] gap-1 @[20rem]:grid-cols-4 @[39rem]:grid-cols-8">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-2 py-1.5 text-left outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-muted">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{detail}</span>
|
||||
</span>
|
||||
{trailing}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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 (
|
||||
<BrowserNewTabRow
|
||||
icon={
|
||||
<BrowserFavicon
|
||||
faviconUrl={faviconDataUrl}
|
||||
className="size-4"
|
||||
fallbackClassName="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
title={title ?? port.processName ?? address}
|
||||
detail={
|
||||
<>
|
||||
<span className="font-mono">{address}</span>
|
||||
{context ? ` · ${context}` : null}
|
||||
</>
|
||||
}
|
||||
onOpen={() => void open()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="group/tile relative min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex w-full min-w-0 flex-col items-center gap-2 rounded-xl px-1.5 pt-3.5 pb-2.5 outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
<span className="grid size-12 place-items-center rounded-xl border border-border bg-muted">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="max-w-full truncate text-xs text-foreground">{label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={removeLabel}
|
||||
onClick={onRemove}
|
||||
className="absolute top-1 right-1 grid size-5 place-items-center rounded-full border border-border bg-popover text-muted-foreground opacity-0 outline-none group-hover/tile:opacity-100 group-focus-within/tile:opacity-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<BrowserNewTabServerRow
|
||||
key={`${server.port.connectHost}:${server.port.port}`}
|
||||
server={server}
|
||||
runtimeTarget={runtimeTarget}
|
||||
openInPlace={openServersInPlace}
|
||||
showOwner={showOwner}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{own.length > 0 || linkedItems.length > 0 ? (
|
||||
<div className="flex flex-col gap-px rounded-xl border border-border bg-card p-1">
|
||||
{own.map((server) => serverRow(server, false))}
|
||||
{own.length > 0 && linkedItems.length > 0 ? (
|
||||
<div className="mx-2 my-1 h-px bg-border" />
|
||||
) : null}
|
||||
{linkedItems.map((row) => (
|
||||
<LinkedItemRow key={row.key} row={row} onOpenUrl={onOpenUrl} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={othersOpen}
|
||||
onClick={() => setOthersOpen((open) => !open)}
|
||||
className="flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 transition-transform motion-reduce:transition-none', {
|
||||
'rotate-90': othersOpen
|
||||
})}
|
||||
/>
|
||||
{translate('browser.newTab.runningElsewhere', '{{count}} running in other workspaces', {
|
||||
count: others.length
|
||||
})}
|
||||
</button>
|
||||
{othersOpen ? (
|
||||
<div className="flex flex-col gap-px">
|
||||
{others.map((server) => serverRow(server, true))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<BrowserNewTabRow
|
||||
icon={<LinkedItemIcon row={row} />}
|
||||
title={detail?.title || item.title || live?.title || workspaceAttachmentLabel(item)}
|
||||
detail={
|
||||
<>
|
||||
<span className="font-mono">{reference}</span>
|
||||
{status ? ` · ${status}` : null}
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
reviewState && live?.state ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-2xs font-medium',
|
||||
getReviewStateTone(live.state)
|
||||
)}
|
||||
>
|
||||
{reviewState}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
onOpen={() => {
|
||||
if (url) {
|
||||
onOpenUrl(url)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkedItemIcon({ row }: { row: WorkspaceLinkedItemRow }): React.JSX.Element {
|
||||
const { item, live } = row
|
||||
if (live) {
|
||||
return <ReviewIcon review={live} className="size-4" />
|
||||
}
|
||||
if (item.provider === 'linear') {
|
||||
return <LinearIcon className="size-4 text-muted-foreground" />
|
||||
}
|
||||
if (item.provider === 'jira') {
|
||||
return <JiraIcon className="size-4 text-muted-foreground" />
|
||||
}
|
||||
if (item.type === 'mr') {
|
||||
return <GitMerge className="size-4 text-muted-foreground" />
|
||||
}
|
||||
if (item.type === 'pr') {
|
||||
return <GitPullRequest className="size-4 text-muted-foreground" />
|
||||
}
|
||||
return (
|
||||
<CircleDot
|
||||
className={
|
||||
linkedIssueState(row) === 'open'
|
||||
? 'size-4 text-workspace-status-review/80'
|
||||
: 'size-4 text-muted-foreground'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<WorkspaceLinkedItemRow, 'detail' | 'liveTask'>): 'open' | 'closed' | undefined {
|
||||
return detail?.issueState ?? (liveTask && 'number' in liveTask ? liveTask.state : undefined)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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<WorkspacePortPageProbeResult | null> }
|
||||
>()
|
||||
|
||||
function probeKey(target: RuntimeClientTarget, port: WorkspacePort): string {
|
||||
return `${workspacePortScanKeyForTarget(target)}|${port.connectHost}|${port.port}|${port.pid ?? ''}`
|
||||
}
|
||||
|
||||
function probePortPage(
|
||||
target: RuntimeClientTarget,
|
||||
port: WorkspacePort
|
||||
): Promise<WorkspacePortPageProbeResult | null> {
|
||||
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<ReadonlyMap<string, WorkspacePortPageProbeResult | null>>(
|
||||
() => 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))
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+9
-4
@@ -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<HTMLImageElement>) => void
|
||||
handleRemotePointerUp: (event: React.PointerEvent<HTMLImageElement>) => 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 (
|
||||
<div
|
||||
@@ -145,6 +147,9 @@ export function RemoteBrowserPageViewport({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isBlankBrowserUrl(browserTab.url) ? (
|
||||
<BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={onNavigateToUrl} />
|
||||
) : null}
|
||||
{showRemoteFailureOverlay && browserTab.loadError ? (
|
||||
<BrowserLoadFailureOverlay
|
||||
loadError={browserTab.loadError}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CircleDot, ExternalLink, GitMerge, GitPullRequest, Link2, Plus } from '
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types'
|
||||
import { getWorkspaceAttachmentKey } from '../../../../shared/workspace-attachment-normalization'
|
||||
import {
|
||||
workspaceAttachmentLabel,
|
||||
workspaceAttachmentProviderLabel
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import { WorkspaceAttachmentOrigins } from './WorkspaceAttachmentOrigins'
|
||||
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
|
||||
import type { WorkspaceReferenceDetailsMap } from './workspace-reference-details'
|
||||
import { getWorkspaceHoverAttachments } from './workspace-hover-attachments'
|
||||
import { getWorkspaceLinkedItemRows } from './workspace-hover-attachments'
|
||||
import type {
|
||||
WorktreeCardIssueDisplay,
|
||||
WorktreeCardLinearIssueDisplay,
|
||||
@@ -52,22 +51,21 @@ export function WorktreeCardLinkedItemsSection({
|
||||
if (!workspace) {
|
||||
return null
|
||||
}
|
||||
const { items, activeKey, referenceKeys } = getWorkspaceHoverAttachments(workspace, {
|
||||
review,
|
||||
issue,
|
||||
linearIssue,
|
||||
jiraIssue
|
||||
})
|
||||
const rows = getWorkspaceLinkedItemRows(
|
||||
workspace,
|
||||
{ review, issue, linearIssue, jiraIssue },
|
||||
referenceDetails
|
||||
)
|
||||
const groups = [
|
||||
{
|
||||
label: translate('workspace.links.reviews', 'Reviews'),
|
||||
items: items.filter((item) => 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({
|
||||
<div key={group.label} className="space-y-1.5">
|
||||
<DetailHeader
|
||||
icon={<Link2 className="size-3" />}
|
||||
label={`${group.label} · ${group.items.length}`}
|
||||
label={`${group.label} · ${group.rows.length}`}
|
||||
/>
|
||||
<div className="divide-y divide-border">
|
||||
{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 (
|
||||
<div
|
||||
key={key}
|
||||
@@ -194,7 +180,7 @@ export function WorktreeCardLinkedItemsSection({
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Plus />
|
||||
{items.length
|
||||
{rows.length
|
||||
? translate('workspace.links.manage', 'Manage links…')
|
||||
: translate('workspace.links.addReference', 'Add reference')}
|
||||
</Button>
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16661,7 +16661,6 @@
|
||||
"95af781091": "Agent にフィードバックを送信する",
|
||||
"ac39b9366b": "送信",
|
||||
"a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。",
|
||||
"f796c774a4": "上に URL を入力して閲覧を開始します。",
|
||||
"366bf5d62c": "新規タブ",
|
||||
"1c78adc73d": "外部で開く",
|
||||
"da68d35f7b": "失敗したページをデフォルトのブラウザで開く",
|
||||
|
||||
@@ -16661,7 +16661,6 @@
|
||||
"95af781091": "agent에게 피드백 보내기",
|
||||
"ac39b9366b": "보내기",
|
||||
"a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.",
|
||||
"f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.",
|
||||
"366bf5d62c": "새 탭",
|
||||
"1c78adc73d": "외부에서 열기",
|
||||
"da68d35f7b": "기본 브라우저에서 실패한 페이지 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16661,7 +16661,6 @@
|
||||
"95af781091": "向智能体发送反馈",
|
||||
"ac39b9366b": "发送",
|
||||
"a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。",
|
||||
"f796c774a4": "在上面输入 URL 即可开始浏览。",
|
||||
"366bf5d62c": "新标签",
|
||||
"1c78adc73d": "在外部打开",
|
||||
"da68d35f7b": "在默认浏览器中打开失败页面",
|
||||
|
||||
@@ -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/'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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)
|
||||
|
||||
@@ -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<WorkspacePortPageProbeResult | null> {
|
||||
if (target.kind === 'local') {
|
||||
return window.api.workspacePorts.probePage(request)
|
||||
}
|
||||
try {
|
||||
return await callRuntimeRpc<WorkspacePortPageProbeResult>(
|
||||
target,
|
||||
'workspacePorts.probePage',
|
||||
request,
|
||||
{ timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS }
|
||||
)
|
||||
} catch (error) {
|
||||
if (isMethodNotFound(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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.'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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: [] })
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ export type BrowserSlice = {
|
||||
clearDefaultSessionCookies: () => Promise<boolean>
|
||||
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: (
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createWebWorkspacePortsApi(): Partial<PreloadApi> {
|
||||
ok: false,
|
||||
reason: 'Workspace port management is unavailable for browser-local workspaces.'
|
||||
}),
|
||||
probePage: () => Promise.resolve({ kind: 'not-page' }),
|
||||
onAdvertisedUrlChanged: () => noopUnsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user