mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
feat: workspace folder selection (#238)
* feat: v1 ui for the file selector * feat: integrate with browseros.choosePath API * feat: gate workspace folder for 0.36.0.4 as requires new browserOS.choosePath API * fix: add default folder option * fix: clean-up old code
This commit is contained in:
188
apps/agent/components/elements/workspace-selector.tsx
Normal file
188
apps/agent/components/elements/workspace-selector.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Check, ChevronDown, Folder, FolderOpen, Home, X } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import type { WorkspaceFolder } from '@/lib/workspace/workspace-storage'
|
||||
|
||||
interface WorkspaceSelectorProps {
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export const WorkspaceSelector: FC<
|
||||
PropsWithChildren<WorkspaceSelectorProps>
|
||||
> = ({ children, side = 'top' }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
recentFolders,
|
||||
selectedFolder,
|
||||
selectFolder,
|
||||
addFolder,
|
||||
removeFolder,
|
||||
clearSelection,
|
||||
} = useWorkspace()
|
||||
|
||||
const handleChooseFolder = async () => {
|
||||
try {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const result = await adapter.choosePath({ type: 'folder' })
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
const folder: WorkspaceFolder = {
|
||||
id: crypto.randomUUID(),
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
addedAt: Date.now(),
|
||||
}
|
||||
|
||||
await addFolder(folder)
|
||||
setOpen(false)
|
||||
} catch {
|
||||
// User cancelled or API not available - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectFolder = async (folder: WorkspaceFolder) => {
|
||||
if (selectedFolder?.id === folder.id) {
|
||||
await clearSelection()
|
||||
} else {
|
||||
await selectFolder(folder)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleRemoveFolder = async (e: React.MouseEvent, folderId: string) => {
|
||||
e.stopPropagation()
|
||||
await removeFolder(folderId)
|
||||
}
|
||||
|
||||
const handleUseDefault = async () => {
|
||||
await clearSelection()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{children || (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
|
||||
selectedFolder && 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{selectedFolder ? selectedFolder.name : 'Work in a folder'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side={side}
|
||||
align="start"
|
||||
className="w-80 p-0"
|
||||
role="dialog"
|
||||
aria-label="Select workspace folder"
|
||||
>
|
||||
<div
|
||||
onClick={handleUseDefault}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleUseDefault()
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={!selectedFolder}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted',
|
||||
!selectedFolder && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm">Use default</span>
|
||||
{!selectedFolder && (
|
||||
<Check className="h-4 w-4 text-[var(--accent-orange)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentFolders.length > 0 && (
|
||||
<>
|
||||
<div className="border-t" />
|
||||
<div className="px-3 py-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Recent
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{recentFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
onClick={() => handleSelectFolder(folder)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleSelectFolder(folder)
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={selectedFolder?.id === folder.id}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-start gap-3 px-3 py-2 transition-colors hover:bg-muted',
|
||||
selectedFolder?.id === folder.id && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">
|
||||
{folder.name}
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{folder.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{selectedFolder?.id === folder.id && (
|
||||
<Check className="h-4 w-4 text-[var(--accent-orange)]" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveFolder(e, folder.id)}
|
||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-muted-foreground/20 group-hover:opacity-100"
|
||||
aria-label={`Remove ${folder.name} from recents`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleChooseFolder}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Choose a different folder</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useCombobox } from 'downshift'
|
||||
import {
|
||||
ArrowRight,
|
||||
File,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Layers,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import type React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
GlowingBorder,
|
||||
GlowingElement,
|
||||
} from '@/components/elements/glowing-border'
|
||||
import { TabSelector } from '@/components/elements/tab-selector'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
createAITabAction,
|
||||
createBrowserOSAction,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { FooterLinks } from './FooterLinks'
|
||||
import type { SuggestionItem } from './lib/suggestions/types'
|
||||
import {
|
||||
@@ -40,25 +43,18 @@ import { SearchSuggestions } from './SearchSuggestions'
|
||||
import { ShortcutsDialog } from './ShortcutsDialog'
|
||||
import { TopSites } from './TopSites'
|
||||
|
||||
interface SelectedFile {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const NewTab = () => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const tabsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { supports } = useCapabilities()
|
||||
|
||||
const toggleTab = (tab: chrome.tabs.Tab) => {
|
||||
setSelectedTabs((prev) => {
|
||||
@@ -147,6 +143,7 @@ export const NewTab = () => {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
action,
|
||||
workingDir: selectedFolder?.path,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -164,6 +161,7 @@ export const NewTab = () => {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
action,
|
||||
workingDir: selectedFolder?.path,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -184,33 +182,6 @@ export const NewTab = () => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const _handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []).slice(0, 2) // Limit to 2 files
|
||||
const fileData = files.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview: file.type.startsWith('image/')
|
||||
? URL.createObjectURL(file)
|
||||
: undefined,
|
||||
}))
|
||||
setSelectedFiles(fileData)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
selectedFiles.forEach((file) => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
{/* Main content */}
|
||||
@@ -221,9 +192,7 @@ export const NewTab = () => {
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-border/50 p-[2px]',
|
||||
isSuggestionsVisible ||
|
||||
selectedTabs.length > 0 ||
|
||||
selectedFiles.length > 0
|
||||
isSuggestionsVisible || selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)]/30 shadow-[var(--accent-orange)]/10'
|
||||
: 'bg-border/50 hover:border-border',
|
||||
)}
|
||||
@@ -242,9 +211,7 @@ export const NewTab = () => {
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-card shadow-lg',
|
||||
isSuggestionsVisible ||
|
||||
selectedTabs.length > 0 ||
|
||||
selectedFiles.length > 0
|
||||
isSuggestionsVisible || selectedTabs.length > 0
|
||||
? 'border-[var(--accent-orange)]/30 shadow-[var(--accent-orange)]/10'
|
||||
: 'border-border/50 hover:border-border',
|
||||
)}
|
||||
@@ -272,7 +239,7 @@ export const NewTab = () => {
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{(selectedTabs.length > 0 || selectedFiles.length > 0) && (
|
||||
{selectedTabs.length > 0 && (
|
||||
<motion.div
|
||||
className="overflow-clip px-5 pb-4"
|
||||
transition={{ duration: 0.2 }}
|
||||
@@ -324,44 +291,6 @@ export const NewTab = () => {
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index.toString()}
|
||||
className="group w-48 flex-shrink-0 rounded-lg border border-border bg-accent/50 p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
|
||||
{file.preview ? (
|
||||
<img
|
||||
src={file.preview || '/placeholder.svg'}
|
||||
alt={file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : file.type.startsWith('image/') ? (
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<File className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 truncate font-medium text-foreground text-sm">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="cursor-pointer rounded p-1 opacity-0 transition-opacity hover:bg-background group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -382,6 +311,25 @@ export const NewTab = () => {
|
||||
{mounted && (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{selectedFolder?.name || 'Work in a folder'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={tabsDropdownRef}>
|
||||
<TabSelector
|
||||
selectedTabs={selectedTabs}
|
||||
@@ -401,19 +349,6 @@ export const NewTab = () => {
|
||||
</Button>
|
||||
</TabSelector>
|
||||
</div>
|
||||
|
||||
{/*<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all ${
|
||||
selectedFiles.length > 0
|
||||
? 'bg-[var(--accent-orange)] text-white shadow-sm'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>Files</span>
|
||||
</button>*/}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -138,6 +138,7 @@ export const useChatSession = () => {
|
||||
|
||||
const modeRef = useRef<ChatMode>(mode)
|
||||
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
|
||||
const workingDirRef = useRef<string | undefined>(undefined)
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
modeRef.current = mode
|
||||
@@ -253,6 +254,7 @@ export const useChatSession = () => {
|
||||
sessionToken: provider?.sessionToken,
|
||||
browserContext,
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
},
|
||||
}
|
||||
@@ -317,6 +319,7 @@ export const useChatSession = () => {
|
||||
const unwatch = searchActionsStorage.watch((storageAction) => {
|
||||
if (storageAction) {
|
||||
setMode(storageAction.mode)
|
||||
workingDirRef.current = storageAction.workingDir
|
||||
sendMessage({ text: storageAction.query, action: storageAction.action })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@ export type Snapshot = chrome.browserOS.Snapshot
|
||||
export type SnapshotOptions = chrome.browserOS.SnapshotOptions
|
||||
export type SnapshotContext = chrome.browserOS.SnapshotContext
|
||||
export type PrefObject = chrome.browserOS.PrefObject
|
||||
export type SelectionType = chrome.browserOS.SelectionType
|
||||
export type ChoosePathOptions = chrome.browserOS.ChoosePathOptions
|
||||
export type SelectedPath = chrome.browserOS.SelectedPath
|
||||
|
||||
export const SCREENSHOT_SIZES = {
|
||||
small: 512,
|
||||
@@ -376,6 +379,28 @@ export class BrowserOSAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async choosePath(options?: ChoosePathOptions): Promise<SelectedPath | null> {
|
||||
if (typeof chrome.browserOS?.choosePath !== 'function') {
|
||||
throw new Error('choosePath API not available')
|
||||
}
|
||||
|
||||
return new Promise<SelectedPath | null>((resolve, reject) => {
|
||||
const callback = (result: SelectedPath | null) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message || 'Unknown error'))
|
||||
} else {
|
||||
resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
if (options) {
|
||||
chrome.browserOS.choosePath(options, callback)
|
||||
} else {
|
||||
chrome.browserOS.choosePath(callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
isAPIAvailable(method: string): boolean {
|
||||
return method in chrome.browserOS
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export enum Feature {
|
||||
UNIFIED_PORT_SUPPORT = 'UNIFIED_PORT_SUPPORT',
|
||||
// Toolbar customization settings
|
||||
CUSTOMIZATION_SUPPORT = 'CUSTOMIZATION_SUPPORT',
|
||||
// Workspace folder selection with full path support requires new browserOS.choosePath API
|
||||
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.PERSONALIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.UNIFIED_PORT_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
|
||||
20
apps/agent/lib/browseros/chrome-browser-os.d.ts
vendored
20
apps/agent/lib/browseros/chrome-browser-os.d.ts
vendored
@@ -253,4 +253,24 @@ declare namespace chrome.browserOS {
|
||||
): void
|
||||
|
||||
function getAllPrefs(callback: (prefs: PrefObject[]) => void): void
|
||||
|
||||
// choosePath types
|
||||
type SelectionType = 'file' | 'folder'
|
||||
|
||||
interface ChoosePathOptions {
|
||||
type?: SelectionType
|
||||
title?: string
|
||||
startingDirectory?: string
|
||||
}
|
||||
|
||||
interface SelectedPath {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function choosePath(
|
||||
options: ChoosePathOptions,
|
||||
callback: (result: SelectedPath | null) => void,
|
||||
): void
|
||||
function choosePath(callback: (result: SelectedPath | null) => void): void
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SearchActionStorage {
|
||||
query: string
|
||||
mode: 'chat' | 'agent'
|
||||
action?: ChatAction
|
||||
workingDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
74
apps/agent/lib/workspace/use-workspace.ts
Normal file
74
apps/agent/lib/workspace/use-workspace.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
selectedWorkspaceStorage,
|
||||
type WorkspaceFolder,
|
||||
workspaceFoldersStorage,
|
||||
} from './workspace-storage'
|
||||
|
||||
const MAX_RECENT_FOLDERS = 10
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const [recentFolders, setRecentFolders] = useState<WorkspaceFolder[]>([])
|
||||
const [selectedFolder, setSelectedFolder] = useState<WorkspaceFolder | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
workspaceFoldersStorage.getValue().then(setRecentFolders)
|
||||
selectedWorkspaceStorage.getValue().then(setSelectedFolder)
|
||||
|
||||
const unwatchRecent = workspaceFoldersStorage.watch((value) => {
|
||||
setRecentFolders(value ?? [])
|
||||
})
|
||||
const unwatchSelected = selectedWorkspaceStorage.watch((value) => {
|
||||
setSelectedFolder(value)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unwatchRecent()
|
||||
unwatchSelected()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectFolder = async (folder: WorkspaceFolder) => {
|
||||
await selectedWorkspaceStorage.setValue(folder)
|
||||
|
||||
const current = (await workspaceFoldersStorage.getValue()) ?? []
|
||||
const filtered = current.filter((f) => f.path !== folder.path)
|
||||
const updated = [{ ...folder, addedAt: Date.now() }, ...filtered].slice(
|
||||
0,
|
||||
MAX_RECENT_FOLDERS,
|
||||
)
|
||||
await workspaceFoldersStorage.setValue(updated)
|
||||
}
|
||||
|
||||
const addFolder = async (folder: WorkspaceFolder) => {
|
||||
await selectFolder(folder)
|
||||
}
|
||||
|
||||
const removeFolder = async (id: string) => {
|
||||
const current = (await workspaceFoldersStorage.getValue()) ?? []
|
||||
await workspaceFoldersStorage.setValue(current.filter((f) => f.id !== id))
|
||||
|
||||
const selected = await selectedWorkspaceStorage.getValue()
|
||||
if (selected?.id === id) {
|
||||
await selectedWorkspaceStorage.setValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = async () => {
|
||||
await selectedWorkspaceStorage.setValue(null)
|
||||
}
|
||||
|
||||
return {
|
||||
recentFolders,
|
||||
selectedFolder,
|
||||
selectFolder,
|
||||
addFolder,
|
||||
removeFolder,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
21
apps/agent/lib/workspace/workspace-storage.ts
Normal file
21
apps/agent/lib/workspace/workspace-storage.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface WorkspaceFolder {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export const workspaceFoldersStorage = storage.defineItem<WorkspaceFolder[]>(
|
||||
'local:workspaceFolders',
|
||||
{ fallback: [] },
|
||||
)
|
||||
|
||||
export const selectedWorkspaceStorage =
|
||||
storage.defineItem<WorkspaceFolder | null>('local:selectedWorkspace', {
|
||||
fallback: null,
|
||||
})
|
||||
Reference in New Issue
Block a user