mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat(browser): multi-profile session management with per-tab switching (#823)
This commit is contained in:
parent
096c1818f3
commit
8dcb234d22
9 changed files with 777 additions and 244 deletions
|
|
@ -1323,6 +1323,20 @@ export async function importCookiesFromBrowser(
|
|||
const partitionName = targetPartition.replace('persist:', '')
|
||||
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
|
||||
|
||||
// Why: Electron only creates the partition's Cookies SQLite file after the
|
||||
// session has actually stored a cookie. For newly created profiles that have
|
||||
// never been used by a webview, the file won't exist yet. Setting and
|
||||
// removing a throwaway cookie forces Electron to initialize the database.
|
||||
if (!existsSync(liveCookiesPath)) {
|
||||
try {
|
||||
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
|
||||
await targetSession.cookies.remove('https://localhost', '__init')
|
||||
await targetSession.cookies.flushStore()
|
||||
} catch {
|
||||
// ignore — the set/remove may fail but flushStore should still create the file
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(liveCookiesPath)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
|
||||
|
|
|
|||
|
|
@ -50,27 +50,36 @@ describe('BrowserSessionRegistry', () => {
|
|||
|
||||
it('creates an isolated profile with a unique partition', () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Test Isolated')
|
||||
expect(profile.scope).toBe('isolated')
|
||||
expect(profile.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
expect(profile.partition).not.toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(profile.label).toBe('Test Isolated')
|
||||
expect(profile.source).toBeNull()
|
||||
expect(profile).not.toBeNull()
|
||||
expect(profile!.scope).toBe('isolated')
|
||||
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
expect(profile!.partition).not.toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(profile!.label).toBe('Test Isolated')
|
||||
expect(profile!.source).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects creating a profile with scope default', () => {
|
||||
const profile = browserSessionRegistry.createProfile('default', 'Sneaky')
|
||||
expect(profile).toBeNull()
|
||||
})
|
||||
|
||||
it('allows created profile partitions', () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Allowed')
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true)
|
||||
expect(profile).not.toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
|
||||
})
|
||||
|
||||
it('creates an imported profile', () => {
|
||||
const profile = browserSessionRegistry.createProfile('imported', 'My Import')
|
||||
expect(profile.scope).toBe('imported')
|
||||
expect(profile.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
expect(profile).not.toBeNull()
|
||||
expect(profile!.scope).toBe('imported')
|
||||
expect(profile!.partition).toMatch(/^persist:orca-browser-session-/)
|
||||
})
|
||||
|
||||
it('resolves partition for a known profile', () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Resolve Test')
|
||||
expect(browserSessionRegistry.resolvePartition(profile.id)).toBe(profile.partition)
|
||||
expect(profile).not.toBeNull()
|
||||
expect(browserSessionRegistry.resolvePartition(profile!.id)).toBe(profile!.partition)
|
||||
})
|
||||
|
||||
it('resolves default partition for null/undefined profileId', () => {
|
||||
|
|
@ -91,7 +100,8 @@ describe('BrowserSessionRegistry', () => {
|
|||
|
||||
it('updates profile source', () => {
|
||||
const profile = browserSessionRegistry.createProfile('imported', 'Source Test')
|
||||
const updated = browserSessionRegistry.updateProfileSource(profile.id, {
|
||||
expect(profile).not.toBeNull()
|
||||
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
|
||||
browserFamily: 'edge',
|
||||
importedAt: Date.now()
|
||||
})
|
||||
|
|
@ -101,11 +111,12 @@ describe('BrowserSessionRegistry', () => {
|
|||
|
||||
it('deletes a non-default profile', async () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test')
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true)
|
||||
const deleted = await browserSessionRegistry.deleteProfile(profile.id)
|
||||
expect(profile).not.toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(true)
|
||||
const deleted = await browserSessionRegistry.deleteProfile(profile!.id)
|
||||
expect(deleted).toBe(true)
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(false)
|
||||
expect(browserSessionRegistry.getProfile(profile.id)).toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false)
|
||||
expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to delete the default profile', async () => {
|
||||
|
|
@ -116,14 +127,14 @@ describe('BrowserSessionRegistry', () => {
|
|||
|
||||
it('hydrates profiles from persisted data', () => {
|
||||
const fakeProfile = {
|
||||
id: 'hydrate-test-id',
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
scope: 'imported' as const,
|
||||
partition: 'persist:orca-browser-session-hydrate-test-id',
|
||||
partition: 'persist:orca-browser-session-00000000-0000-0000-0000-000000000001',
|
||||
label: 'Hydrated',
|
||||
source: { browserFamily: 'manual' as const, importedAt: 1000 }
|
||||
}
|
||||
browserSessionRegistry.hydrateFromPersisted([fakeProfile])
|
||||
expect(browserSessionRegistry.getProfile('hydrate-test-id')).not.toBeNull()
|
||||
expect(browserSessionRegistry.getProfile('00000000-0000-0000-0000-000000000001')).not.toBeNull()
|
||||
expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { app, type Session, session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
|
||||
|
|
@ -10,6 +17,7 @@ type BrowserSessionMeta = {
|
|||
defaultSource: BrowserSessionProfile['source']
|
||||
userAgent: string | null
|
||||
pendingCookieDbPath: string | null
|
||||
profiles: BrowserSessionProfile[]
|
||||
}
|
||||
|
||||
// Why: the registry is the single source of truth for which Electron partitions
|
||||
|
|
@ -43,10 +51,14 @@ class BrowserSessionRegistry {
|
|||
return this.loadPersistedMeta().defaultSource
|
||||
}
|
||||
|
||||
// Why: write-to-temp-then-rename is atomic on all supported platforms.
|
||||
// A crash mid-write would only lose the temp file, not corrupt the live one.
|
||||
private persistMeta(updates: Partial<BrowserSessionMeta>): void {
|
||||
try {
|
||||
const existing = this.loadPersistedMeta()
|
||||
writeFileSync(this.metadataPath, JSON.stringify({ ...existing, ...updates }))
|
||||
const tmpPath = `${this.metadataPath}.tmp`
|
||||
writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates }))
|
||||
renameSync(tmpPath, this.metadataPath)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
|
@ -59,6 +71,13 @@ class BrowserSessionRegistry {
|
|||
})
|
||||
}
|
||||
|
||||
// Why: non-default profiles are in-memory only unless explicitly persisted.
|
||||
// Without this, created profiles vanish on app restart.
|
||||
private persistProfiles(): void {
|
||||
const nonDefault = [...this.profiles.values()].filter((p) => p.id !== 'default')
|
||||
this.persistMeta({ profiles: nonDefault })
|
||||
}
|
||||
|
||||
private loadPersistedMeta(): BrowserSessionMeta {
|
||||
try {
|
||||
const raw = readFileSync(this.metadataPath, 'utf-8')
|
||||
|
|
@ -66,10 +85,11 @@ class BrowserSessionRegistry {
|
|||
return {
|
||||
defaultSource: data?.defaultSource ?? null,
|
||||
userAgent: data?.userAgent ?? null,
|
||||
pendingCookieDbPath: data?.pendingCookieDbPath ?? null
|
||||
pendingCookieDbPath: data?.pendingCookieDbPath ?? null,
|
||||
profiles: Array.isArray(data?.profiles) ? data.profiles : []
|
||||
}
|
||||
} catch {
|
||||
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null }
|
||||
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +115,9 @@ class BrowserSessionRegistry {
|
|||
this.profiles.set('default', { ...current, source: meta.defaultSource })
|
||||
}
|
||||
}
|
||||
if (meta.profiles.length > 0) {
|
||||
this.hydrateFromPersisted(meta.profiles)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
|
||||
|
|
@ -222,13 +245,19 @@ class BrowserSessionRegistry {
|
|||
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
|
||||
}
|
||||
|
||||
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile {
|
||||
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
|
||||
// Why: only the constructor may create the default profile. Allowing the
|
||||
// renderer to pass scope:'default' would create a second profile sharing
|
||||
// ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage
|
||||
// for the shared partition).
|
||||
if (scope === 'default') {
|
||||
return null
|
||||
}
|
||||
const id = randomUUID()
|
||||
// Why: partition names are deterministic from the profile id so main can
|
||||
// reconstruct the allowlist on restart from persisted profile metadata
|
||||
// without needing a separate partition→profile mapping.
|
||||
const partition =
|
||||
scope === 'default' ? ORCA_BROWSER_PARTITION : `persist:orca-browser-session-${id}`
|
||||
const partition = `persist:orca-browser-session-${id}`
|
||||
const profile: BrowserSessionProfile = {
|
||||
id,
|
||||
scope,
|
||||
|
|
@ -237,9 +266,8 @@ class BrowserSessionRegistry {
|
|||
source: null
|
||||
}
|
||||
this.profiles.set(id, profile)
|
||||
if (partition !== ORCA_BROWSER_PARTITION) {
|
||||
this.setupSessionPolicies(partition)
|
||||
}
|
||||
this.setupSessionPolicies(partition)
|
||||
this.persistProfiles()
|
||||
return profile
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +283,8 @@ class BrowserSessionRegistry {
|
|||
this.profiles.set(profileId, updated)
|
||||
if (profileId === 'default') {
|
||||
this.persistSource(source)
|
||||
} else {
|
||||
this.persistProfiles()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
|
@ -265,6 +295,7 @@ class BrowserSessionRegistry {
|
|||
return false
|
||||
}
|
||||
this.profiles.delete(profileId)
|
||||
this.persistProfiles()
|
||||
|
||||
// Why: clearing the partition's storage prevents orphaned cookies/cache from
|
||||
// lingering after the user deletes an imported or isolated session profile.
|
||||
|
|
@ -303,9 +334,23 @@ class BrowserSessionRegistry {
|
|||
// Why: on startup, main must reconstruct the set of valid partitions from
|
||||
// persisted session profiles so restored webviews are not denied by
|
||||
// will-attach-webview before the renderer mounts them.
|
||||
// Why: profiles are deserialized from a JSON file on disk. A corrupted or
|
||||
// tampered file could inject an arbitrary partition into the allowlist that
|
||||
// will-attach-webview trusts, so we validate the expected shape before
|
||||
// registering anything.
|
||||
private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/
|
||||
|
||||
hydrateFromPersisted(profiles: BrowserSessionProfile[]): void {
|
||||
for (const profile of profiles) {
|
||||
if (profile.id === 'default') {
|
||||
if (profile.id === 'default' || profile.scope === 'default') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
typeof profile.id !== 'string' ||
|
||||
typeof profile.partition !== 'string' ||
|
||||
typeof profile.label !== 'string' ||
|
||||
!BrowserSessionRegistry.PARTITION_RE.test(profile.partition)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.profiles.set(profile.id, profile)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import {
|
|||
ExternalLink,
|
||||
Globe,
|
||||
Image,
|
||||
Import,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
SquareCode
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -57,6 +55,7 @@ import { useGrabMode } from './useGrabMode'
|
|||
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
|
||||
import { isEditableKeyboardTarget } from './browser-keyboard'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
|
||||
import BrowserFind from './BrowserFind'
|
||||
import {
|
||||
consumeBrowserFocusRequest,
|
||||
|
|
@ -1207,12 +1206,16 @@ function BrowserPagePane({
|
|||
}
|
||||
}
|
||||
// Why: this effect mounts and wires up webview event listeners once per tab
|
||||
// identity. browserTab.url and webviewPartition are intentionally excluded:
|
||||
// re-running on URL changes would detach/reattach the webview, cancelling
|
||||
// in-progress navigations. Callbacks use refs so they always see current values.
|
||||
// identity. browserTab.url is intentionally excluded: re-running on URL
|
||||
// changes would detach/reattach the webview, cancelling in-progress
|
||||
// navigations. Callbacks use refs so they always see current values.
|
||||
// webviewPartition IS included: switching profiles changes the partition,
|
||||
// which requires destroying and recreating the webview since Electron does
|
||||
// not allow changing a webview's partition after creation.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
browserTab.id,
|
||||
webviewPartition,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
createBrowserTab,
|
||||
|
|
@ -1817,28 +1820,11 @@ function BrowserPagePane({
|
|||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
|
||||
{sessionProfile && (
|
||||
<div
|
||||
className="flex h-6 items-center gap-1 rounded-md bg-accent/60 px-2 text-[10px] font-medium text-muted-foreground"
|
||||
title={`Session: ${sessionProfile.label}${sessionProfile.source?.browserFamily ? ` (${sessionProfile.source.browserFamily})` : ''}`}
|
||||
>
|
||||
<Import className="size-3" />
|
||||
{sessionProfile.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title="Browser Settings"
|
||||
onClick={() => {
|
||||
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
|
||||
useAppStore.getState().openSettingsPage()
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<BrowserToolbarMenu
|
||||
currentProfileId={sessionProfileId}
|
||||
workspaceId={workspaceId}
|
||||
onDestroyWebview={() => destroyPersistentWebview(browserTab.id)}
|
||||
/>
|
||||
</div>
|
||||
{downloadState ? (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-foreground/90">
|
||||
|
|
|
|||
305
src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx
Normal file
305
src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { useState } from 'react'
|
||||
import { Check, Ellipsis, Import, Plus, Settings } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAppStore } from '@/store'
|
||||
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
|
||||
|
||||
type BrowserToolbarMenuProps = {
|
||||
currentProfileId: string | null
|
||||
workspaceId: string
|
||||
onDestroyWebview: () => void
|
||||
}
|
||||
|
||||
export function BrowserToolbarMenu({
|
||||
currentProfileId,
|
||||
workspaceId,
|
||||
onDestroyWebview
|
||||
}: BrowserToolbarMenuProps): React.JSX.Element {
|
||||
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
|
||||
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
|
||||
const switchBrowserTabProfile = useAppStore((s) => s.switchBrowserTabProfile)
|
||||
const createBrowserSessionProfile = useAppStore((s) => s.createBrowserSessionProfile)
|
||||
const importCookiesFromBrowser = useAppStore((s) => s.importCookiesFromBrowser)
|
||||
const importCookiesToProfile = useAppStore((s) => s.importCookiesToProfile)
|
||||
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
|
||||
|
||||
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
|
||||
const [newProfileName, setNewProfileName] = useState('')
|
||||
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
|
||||
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState<string | null | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const effectiveProfileId = currentProfileId ?? 'default'
|
||||
|
||||
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
|
||||
// Why: Default profile always appears first in the list and cannot be deleted.
|
||||
// Non-default profiles follow in their natural order.
|
||||
const allProfiles = defaultProfile
|
||||
? [defaultProfile, ...browserSessionProfiles.filter((p) => p.id !== 'default')]
|
||||
: browserSessionProfiles
|
||||
|
||||
const handleSwitchProfile = (profileId: string | null): void => {
|
||||
const targetId = profileId ?? 'default'
|
||||
if (targetId === effectiveProfileId) {
|
||||
return
|
||||
}
|
||||
setPendingSwitchProfileId(profileId)
|
||||
}
|
||||
|
||||
const confirmSwitchProfile = (): void => {
|
||||
if (pendingSwitchProfileId === undefined) {
|
||||
return
|
||||
}
|
||||
const targetId = pendingSwitchProfileId ?? 'default'
|
||||
// Why: Must destroy before store update. The webviewRegistry is keyed by
|
||||
// workspace ID (stable across switches). Without explicit destroy, the mount
|
||||
// effect would reclaim the old webview with the stale partition.
|
||||
onDestroyWebview()
|
||||
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId)
|
||||
const profile = browserSessionProfiles.find((p) => p.id === targetId)
|
||||
toast.success(`Switched to ${profile?.label ?? 'Default'} profile`)
|
||||
setPendingSwitchProfileId(undefined)
|
||||
}
|
||||
|
||||
const handleCreateProfile = async (): Promise<void> => {
|
||||
const trimmed = newProfileName.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingProfile(true)
|
||||
try {
|
||||
const profile = await createBrowserSessionProfile('isolated', trimmed)
|
||||
if (!profile) {
|
||||
toast.error('Failed to create profile.')
|
||||
return
|
||||
}
|
||||
|
||||
setNewProfileDialogOpen(false)
|
||||
setNewProfileName('')
|
||||
|
||||
onDestroyWebview()
|
||||
switchBrowserTabProfile(workspaceId, profile.id)
|
||||
toast.success(`Created and switched to ${profile.label} profile`)
|
||||
} finally {
|
||||
setIsCreatingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportFromBrowser = async (
|
||||
browserFamily: string,
|
||||
browserProfile?: string
|
||||
): Promise<void> => {
|
||||
const result = await importCookiesFromBrowser(effectiveProfileId, browserFamily, browserProfile)
|
||||
if (result.ok) {
|
||||
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportFromFile = async (): Promise<void> => {
|
||||
const result = await importCookiesToProfile(effectiveProfileId)
|
||||
if (result.ok) {
|
||||
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
|
||||
} else if (result.reason !== 'canceled') {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" title="Browser menu">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{allProfiles.map((profile) => {
|
||||
const isActive = profile.id === effectiveProfileId
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={profile.id}
|
||||
onSelect={() => handleSwitchProfile(profile.id === 'default' ? null : profile.id)}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 size-3.5 shrink-0 ${isActive ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="truncate">{profile.label}</span>
|
||||
{profile.source?.browserFamily && (
|
||||
<span className="ml-auto pl-2 text-[10px] text-muted-foreground">
|
||||
{BROWSER_FAMILY_LABELS[profile.source.browserFamily] ??
|
||||
profile.source.browserFamily}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onSelect={() => setNewProfileDialogOpen(true)}>
|
||||
<Plus className="mr-2 size-3.5" />
|
||||
New Profile…
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={browserSessionImportState?.status === 'importing'}>
|
||||
<Import className="mr-2 size-3.5" />
|
||||
Import Cookies
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{detectedBrowsers.map((browser) =>
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{browser.profiles.map((profile) => (
|
||||
<DropdownMenuItem
|
||||
key={profile.directory}
|
||||
onSelect={() =>
|
||||
void handleImportFromBrowser(browser.family, profile.directory)
|
||||
}
|
||||
>
|
||||
{profile.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={browser.family}
|
||||
onSelect={() => void handleImportFromBrowser(browser.family)}
|
||||
>
|
||||
From {browser.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
|
||||
From File…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
|
||||
useAppStore.getState().openSettingsPage()
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-2 size-3.5" />
|
||||
Browser Settings…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog
|
||||
open={pendingSwitchProfileId !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingSwitchProfileId(undefined)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">Switch Profile</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Switching profiles will reload this page. Any unsaved form data will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPendingSwitchProfileId(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmSwitchProfile}>
|
||||
Switch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={newProfileDialogOpen} onOpenChange={setNewProfileDialogOpen}>
|
||||
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void handleCreateProfile()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
placeholder="Profile name"
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
className="mb-4"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNewProfileDialogOpen(false)
|
||||
setNewProfileName('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!newProfileName.trim() || isCreatingProfile}
|
||||
>
|
||||
{isCreatingProfile ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,38 +1,18 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Import, Loader2, Trash2 } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
|
||||
import { useAppStore } from '../../store'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { BROWSER_PANE_SEARCH_ENTRIES } from './browser-search'
|
||||
|
||||
const BROWSER_FAMILY_LABELS: Record<string, string> = {
|
||||
chrome: 'Google Chrome',
|
||||
chromium: 'Chromium',
|
||||
arc: 'Arc',
|
||||
edge: 'Microsoft Edge',
|
||||
brave: 'Brave',
|
||||
firefox: 'Firefox',
|
||||
safari: 'Safari',
|
||||
manual: 'File'
|
||||
}
|
||||
import { BrowserProfileRow } from './BrowserProfileRow'
|
||||
|
||||
export { BROWSER_PANE_SEARCH_ENTRIES }
|
||||
|
||||
|
|
@ -45,12 +25,17 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
|
|||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
|
||||
const setBrowserDefaultUrl = useAppStore((s) => s.setBrowserDefaultUrl)
|
||||
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
|
||||
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
|
||||
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
|
||||
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
|
||||
const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId)
|
||||
const setDefaultBrowserSessionProfileId = useAppStore((s) => s.setDefaultBrowserSessionProfileId)
|
||||
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
|
||||
const orphanedProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
|
||||
const nonDefaultProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
|
||||
const [homePageDraft, setHomePageDraft] = useState(browserDefaultUrl ?? '')
|
||||
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
|
||||
const [newProfileName, setNewProfileName] = useState('')
|
||||
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
|
||||
|
||||
// Why: sync draft with store value whenever it changes externally (e.g. the
|
||||
// in-app browser tab's address bar saves a home page). Without this, the
|
||||
|
|
@ -145,7 +130,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
|
|||
{showCookies ? (
|
||||
<SearchableSetting
|
||||
title="Session & Cookies"
|
||||
description="Import cookies from Chrome, Edge, or other browsers to use existing logins inside Orca."
|
||||
description="Manage browser profiles and import cookies from Chrome, Edge, or other browsers."
|
||||
keywords={[
|
||||
'cookies',
|
||||
'session',
|
||||
|
|
@ -163,173 +148,120 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
|
|||
<div className="space-y-0.5">
|
||||
<Label>Session & Cookies</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Import cookies from your system browser to reuse existing logins inside Orca.
|
||||
Select a default profile for new browser tabs. Import cookies and switch profiles
|
||||
per-tab via the <strong>···</strong> toolbar menu.
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={browserSessionImportState?.status === 'importing'}
|
||||
>
|
||||
{browserSessionImportState?.status === 'importing' ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Import className="size-3" />
|
||||
)}
|
||||
Import Cookies
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{detectedBrowsers.map((browser) =>
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{browser.profiles.map((profile) => (
|
||||
<DropdownMenuItem
|
||||
key={profile.directory}
|
||||
onSelect={async () => {
|
||||
const store = useAppStore.getState()
|
||||
const result = await store.importCookiesFromBrowser(
|
||||
'default',
|
||||
browser.family,
|
||||
profile.directory
|
||||
)
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from ${browser.label} (${profile.name}).`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{profile.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={browser.family}
|
||||
onSelect={async () => {
|
||||
const store = useAppStore.getState()
|
||||
const result = await store.importCookiesFromBrowser(
|
||||
'default',
|
||||
browser.family
|
||||
)
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from ${browser.label}.`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}}
|
||||
>
|
||||
From {browser.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const store = useAppStore.getState()
|
||||
const result = await store.importCookiesToProfile('default')
|
||||
if (result.ok) {
|
||||
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
|
||||
} else if (result.reason !== 'canceled') {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}}
|
||||
>
|
||||
From File…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setNewProfileDialogOpen(true)}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{defaultProfile?.source ? (
|
||||
<div className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
Imported from{' '}
|
||||
{BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ??
|
||||
defaultProfile.source.browserFamily}
|
||||
{defaultProfile.source.profileName
|
||||
? ` (${defaultProfile.source.profileName})`
|
||||
: ''}
|
||||
</span>
|
||||
{defaultProfile.source.importedAt ? (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{new Date(defaultProfile.source.importedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await useAppStore.getState().clearDefaultSessionCookies()
|
||||
if (ok) {
|
||||
toast.success('Cookies cleared.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{orphanedProfiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orphanedProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">{profile.label}</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{profile.source
|
||||
? `Imported from ${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
|
||||
: 'Unused session'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await useAppStore
|
||||
.getState()
|
||||
.deleteBrowserSessionProfile(profile.id)
|
||||
if (ok) {
|
||||
toast.success('Session removed.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<BrowserProfileRow
|
||||
profile={
|
||||
defaultProfile ?? {
|
||||
id: 'default',
|
||||
scope: 'default',
|
||||
partition: '',
|
||||
label: 'Default',
|
||||
source: null
|
||||
}
|
||||
}
|
||||
detectedBrowsers={detectedBrowsers}
|
||||
importState={browserSessionImportState}
|
||||
isActive={(defaultBrowserSessionProfileId ?? 'default') === 'default'}
|
||||
onSelect={() => setDefaultBrowserSessionProfileId(null)}
|
||||
isDefault
|
||||
/>
|
||||
{nonDefaultProfiles.map((profile) => (
|
||||
<BrowserProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
detectedBrowsers={detectedBrowsers}
|
||||
importState={browserSessionImportState}
|
||||
isActive={(defaultBrowserSessionProfileId ?? 'default') === profile.id}
|
||||
onSelect={() => setDefaultBrowserSessionProfileId(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={newProfileDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewProfileDialogOpen(false)
|
||||
setNewProfileName('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
const trimmed = newProfileName.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
setIsCreatingProfile(true)
|
||||
try {
|
||||
const profile = await useAppStore
|
||||
.getState()
|
||||
.createBrowserSessionProfile('isolated', trimmed)
|
||||
if (profile) {
|
||||
setNewProfileDialogOpen(false)
|
||||
setNewProfileName('')
|
||||
toast.success(`Profile "${profile.label}" created.`)
|
||||
} else {
|
||||
toast.error('Failed to create profile.')
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingProfile(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
placeholder="Profile name"
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
className="mb-4"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNewProfileDialogOpen(false)
|
||||
setNewProfileName('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!newProfileName.trim() || isCreatingProfile}
|
||||
>
|
||||
{isCreatingProfile ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
192
src/renderer/src/components/settings/BrowserProfileRow.tsx
Normal file
192
src/renderer/src/components/settings/BrowserProfileRow.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Import, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { BrowserCookieImportSummary, BrowserSessionProfile } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { useAppStore } from '../../store'
|
||||
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
|
||||
|
||||
type DetectedBrowser = {
|
||||
family: string
|
||||
label: string
|
||||
profiles: { name: string; directory: string }[]
|
||||
selectedProfile: string
|
||||
}
|
||||
|
||||
export type BrowserProfileRowProps = {
|
||||
profile: BrowserSessionProfile
|
||||
detectedBrowsers: DetectedBrowser[]
|
||||
importState: {
|
||||
profileId: string
|
||||
status: 'idle' | 'importing' | 'success' | 'error'
|
||||
summary: BrowserCookieImportSummary | null
|
||||
error: string | null
|
||||
} | null
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export function BrowserProfileRow({
|
||||
profile,
|
||||
detectedBrowsers,
|
||||
importState,
|
||||
isActive,
|
||||
onSelect,
|
||||
isDefault
|
||||
}: BrowserProfileRowProps): React.JSX.Element {
|
||||
const isImporting = importState?.profileId === profile.id && importState.status === 'importing'
|
||||
|
||||
const handleImportFromBrowser = async (
|
||||
browserFamily: string,
|
||||
browserProfile?: string
|
||||
): Promise<void> => {
|
||||
const result = await useAppStore
|
||||
.getState()
|
||||
.importCookiesFromBrowser(profile.id, browserFamily, browserProfile)
|
||||
if (result.ok) {
|
||||
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''} into ${profile.label}.`
|
||||
)
|
||||
} else {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportFromFile = async (): Promise<void> => {
|
||||
const result = await useAppStore.getState().importCookiesToProfile(profile.id)
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from file into ${profile.label}.`
|
||||
)
|
||||
} else if (result.reason !== 'canceled') {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLabel = profile.source
|
||||
? `${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
isActive
|
||||
? 'border-foreground/20 bg-accent/15'
|
||||
: 'border-border/70 hover:border-border hover:bg-accent/8'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{profile.label}</span>
|
||||
{isActive ? (
|
||||
<span className="shrink-0 rounded border border-border/50 px-1.5 text-[10px] font-medium leading-4 text-foreground/80">
|
||||
Active
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{sourceLabel ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">{sourceLabel}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">No cookies imported</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground"
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Import className="size-3" />
|
||||
)}
|
||||
Import Cookies
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{detectedBrowsers.map((browser) =>
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{browser.profiles.map((bp) => (
|
||||
<DropdownMenuItem
|
||||
key={bp.directory}
|
||||
onSelect={() =>
|
||||
void handleImportFromBrowser(browser.family, bp.directory)
|
||||
}
|
||||
>
|
||||
{bp.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={browser.family}
|
||||
onSelect={() => void handleImportFromBrowser(browser.family)}
|
||||
>
|
||||
From {browser.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
|
||||
From File…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isDefault ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={!profile.source}
|
||||
onClick={async () => {
|
||||
const ok = await useAppStore.getState().clearDefaultSessionCookies()
|
||||
if (ok) {
|
||||
toast.success('Default cookies cleared.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await useAppStore.getState().deleteBrowserSessionProfile(profile.id)
|
||||
if (ok) {
|
||||
toast.success(`Profile "${profile.label}" removed.`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ export type BrowserSlice = {
|
|||
setBrowserTabUrl: (pageId: string, url: string) => void
|
||||
setBrowserPageUrl: (pageId: string, url: string) => void
|
||||
hydrateBrowserSession: (session: WorkspaceSessionState) => void
|
||||
switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void
|
||||
browserSessionProfiles: BrowserSessionProfile[]
|
||||
browserSessionImportState: {
|
||||
profileId: string
|
||||
|
|
@ -101,6 +102,8 @@ export type BrowserSlice = {
|
|||
browserUrlHistory: BrowserHistoryEntry[]
|
||||
addBrowserHistoryEntry: (url: string, title: string) => void
|
||||
clearBrowserHistory: () => void
|
||||
defaultBrowserSessionProfileId: string | null
|
||||
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
|
||||
}
|
||||
|
||||
const MAX_BROWSER_HISTORY_ENTRIES = 200
|
||||
|
|
@ -293,16 +296,28 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
browserSessionProfiles: [],
|
||||
browserSessionImportState: null,
|
||||
browserUrlHistory: [],
|
||||
defaultBrowserSessionProfileId: null,
|
||||
|
||||
setDefaultBrowserSessionProfileId: (profileId) => {
|
||||
set({ defaultBrowserSessionProfileId: profileId })
|
||||
},
|
||||
|
||||
createBrowserTab: (worktreeId, url, options) => {
|
||||
const workspaceId = globalThis.crypto.randomUUID()
|
||||
const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title)
|
||||
// Why: when no explicit profile is passed, inherit the user's chosen default
|
||||
// profile. This lets users set a preferred profile in Settings that all new
|
||||
// browser tabs use automatically.
|
||||
const sessionProfileId =
|
||||
options?.sessionProfileId !== undefined
|
||||
? options.sessionProfileId
|
||||
: get().defaultBrowserSessionProfileId
|
||||
const browserTab = buildWorkspaceFromPage(
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
page,
|
||||
[page.id],
|
||||
options?.sessionProfileId
|
||||
sessionProfileId
|
||||
)
|
||||
|
||||
set((s) => {
|
||||
|
|
@ -1064,6 +1079,25 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
}
|
||||
},
|
||||
|
||||
switchBrowserTabProfile: (workspaceId, profileId) => {
|
||||
set((s) => {
|
||||
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
|
||||
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
|
||||
if (tabIndex !== -1) {
|
||||
const updatedTabs = [...tabs]
|
||||
updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], sessionProfileId: profileId }
|
||||
return {
|
||||
browserTabsByWorktree: {
|
||||
...s.browserTabsByWorktree,
|
||||
[worktreeId]: updatedTabs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
},
|
||||
|
||||
fetchBrowserSessionProfiles: async () => {
|
||||
try {
|
||||
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
|
||||
|
|
@ -1095,7 +1129,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
const ok = await window.api.browser.sessionDeleteProfile({ profileId })
|
||||
if (ok) {
|
||||
set((s) => ({
|
||||
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId)
|
||||
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId),
|
||||
...(s.defaultBrowserSessionProfileId === profileId
|
||||
? { defaultBrowserSessionProfileId: null }
|
||||
: {})
|
||||
}))
|
||||
}
|
||||
return ok
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
|
|||
// data URL while still rejecting arbitrary renderer-provided data URLs.
|
||||
export const ORCA_BROWSER_BLANK_URL = 'data:text/html,'
|
||||
|
||||
export const BROWSER_FAMILY_LABELS: Record<string, string> = {
|
||||
chrome: 'Google Chrome',
|
||||
chromium: 'Chromium',
|
||||
arc: 'Arc',
|
||||
edge: 'Microsoft Edge',
|
||||
brave: 'Brave',
|
||||
firefox: 'Firefox',
|
||||
safari: 'Safari',
|
||||
manual: 'File'
|
||||
}
|
||||
|
||||
// Pick a default terminal font that is likely to exist on the current OS.
|
||||
// buildFontFamily() adds the full cross-platform fallback chain, so this only
|
||||
// affects what users see in Settings as the initial value.
|
||||
|
|
|
|||
Loading…
Reference in a new issue