diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts new file mode 100644 index 00000000000..20c990a37f9 --- /dev/null +++ b/apps/web/src/browserPanelStore.ts @@ -0,0 +1,157 @@ +import type { ProjectId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export interface BrowserTab { + id: string; + url: string; + title: string; +} + +interface ProjectBrowserState { + tabs: BrowserTab[]; + activeTabId: string; +} + +const BROWSER_PANEL_STORAGE_KEY = "t3code:browser-panel:v1"; + +let nextTabId = 1; +function generateTabId(): string { + return `browser-tab-${Date.now()}-${nextTabId++}`; +} + +const DEFAULT_PROJECT_BROWSER_STATE: ProjectBrowserState = { + tabs: [], + activeTabId: "", +}; + +function getProjectState( + stateByProjectId: Record, + projectId: ProjectId, +): ProjectBrowserState { + return stateByProjectId[projectId] ?? DEFAULT_PROJECT_BROWSER_STATE; +} + +interface BrowserPanelStoreState { + browserStateByProjectId: Record; + openUrlInProject: (projectId: ProjectId, url: string) => void; + addTab: (projectId: ProjectId, url?: string) => void; + closeTab: (projectId: ProjectId, tabId: string) => void; + setActiveTab: (projectId: ProjectId, tabId: string) => void; + navigateTab: (projectId: ProjectId, tabId: string, url: string) => void; + setTabTitle: (projectId: ProjectId, tabId: string, title: string) => void; +} + +export const useBrowserPanelStore = create()( + persist( + (set) => { + const updateProject = ( + projectId: ProjectId, + updater: (state: ProjectBrowserState) => ProjectBrowserState, + ) => { + set((store) => { + const current = getProjectState(store.browserStateByProjectId, projectId); + const next = updater(current); + if (next === current) return store; + return { + browserStateByProjectId: { + ...store.browserStateByProjectId, + [projectId]: next, + }, + }; + }); + }; + + return { + browserStateByProjectId: {}, + + openUrlInProject: (projectId, url) => { + updateProject(projectId, (state) => { + // If there are no tabs, create one with this URL + if (state.tabs.length === 0) { + const id = generateTabId(); + return { + tabs: [{ id, url, title: url }], + activeTabId: id, + }; + } + // Navigate the active tab to this URL + return { + ...state, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId ? { ...tab, url, title: url } : tab, + ), + }; + }); + }, + + addTab: (projectId, url) => { + updateProject(projectId, (state) => { + const id = generateTabId(); + const tabUrl = url ?? "about:blank"; + return { + tabs: [...state.tabs, { id, url: tabUrl, title: tabUrl }], + activeTabId: id, + }; + }); + }, + + closeTab: (projectId, tabId) => { + updateProject(projectId, (state) => { + const remaining = state.tabs.filter((tab) => tab.id !== tabId); + if (remaining.length === 0) { + return DEFAULT_PROJECT_BROWSER_STATE; + } + const closedIndex = state.tabs.findIndex((tab) => tab.id === tabId); + const nextActiveId = + state.activeTabId === tabId + ? (remaining[Math.min(closedIndex, remaining.length - 1)]?.id ?? remaining[0]?.id ?? "") + : state.activeTabId; + return { tabs: remaining, activeTabId: nextActiveId }; + }); + }, + + setActiveTab: (projectId, tabId) => { + updateProject(projectId, (state) => { + if (state.activeTabId === tabId) return state; + if (!state.tabs.some((tab) => tab.id === tabId)) return state; + return { ...state, activeTabId: tabId }; + }); + }, + + navigateTab: (projectId, tabId, url) => { + updateProject(projectId, (state) => ({ + ...state, + tabs: state.tabs.map((tab) => + tab.id === tabId ? { ...tab, url, title: url } : tab, + ), + })); + }, + + setTabTitle: (projectId, tabId, title) => { + updateProject(projectId, (state) => ({ + ...state, + tabs: state.tabs.map((tab) => + tab.id === tabId ? { ...tab, title } : tab, + ), + })); + }, + }; + }, + { + name: BROWSER_PANEL_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + browserStateByProjectId: state.browserStateByProjectId, + }), + }, + ), +); + +export function selectProjectBrowserState( + browserStateByProjectId: Record, + projectId: ProjectId, +): ProjectBrowserState { + return browserStateByProjectId[projectId] ?? DEFAULT_PROJECT_BROWSER_STATE; +} diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx new file mode 100644 index 00000000000..00e274cc9de --- /dev/null +++ b/apps/web/src/components/BrowserPanel.tsx @@ -0,0 +1,260 @@ +import type { ProjectId } from "@t3tools/contracts"; +import { + ArrowLeftIcon, + ArrowRightIcon, + ExternalLinkIcon, + PlusIcon, + RefreshCwIcon, + XIcon, +} from "lucide-react"; +import { memo, useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { + useBrowserPanelStore, + selectProjectBrowserState, + type BrowserTab, +} from "../browserPanelStore"; +import { cn } from "~/lib/utils"; +import { readNativeApi } from "~/nativeApi"; + +interface BrowserPanelProps { + projectId: ProjectId; +} + +function BrowserTabButton({ + tab, + isActive, + onSelect, + onClose, +}: { + tab: BrowserTab; + isActive: boolean; + onSelect: () => void; + onClose: () => void; +}) { + const displayTitle = tab.title || tab.url || "New Tab"; + const shortTitle = displayTitle.length > 24 ? `${displayTitle.slice(0, 24)}...` : displayTitle; + + return ( + + + ); +} + +function BrowserUrlBar({ + url, + onNavigate, + onOpenExternal, + iframeRef, +}: { + url: string; + onNavigate: (url: string) => void; + onOpenExternal: () => void; + iframeRef: React.RefObject; +}) { + const [inputValue, setInputValue] = useState(url); + const inputRef = useRef(null); + + // Keep input in sync when url prop changes (tab switch, programmatic nav) + const lastUrlRef = useRef(url); + if (url !== lastUrlRef.current) { + lastUrlRef.current = url; + setInputValue(url); + } + + const handleSubmit = useCallback(() => { + let normalized = inputValue.trim(); + if (normalized.length === 0) return; + if (!/^https?:\/\//i.test(normalized)) { + normalized = `http://${normalized}`; + } + onNavigate(normalized); + }, [inputValue, onNavigate]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + const handleRefresh = useCallback(() => { + const iframe = iframeRef.current; + if (iframe) { + iframe.src = iframe.src; + } + }, [iframeRef]); + + return ( +
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmit} + placeholder="Enter URL..." + spellCheck={false} + /> + +
+ ); +} + +const BrowserPanel = memo(function BrowserPanel({ projectId }: BrowserPanelProps) { + const browserState = useBrowserPanelStore((store) => + selectProjectBrowserState(store.browserStateByProjectId, projectId), + ); + const addTab = useBrowserPanelStore((store) => store.addTab); + const closeTab = useBrowserPanelStore((store) => store.closeTab); + const setActiveTab = useBrowserPanelStore((store) => store.setActiveTab); + const navigateTab = useBrowserPanelStore((store) => store.navigateTab); + + const iframeRef = useRef(null); + + const activeTab = browserState.tabs.find((tab) => tab.id === browserState.activeTabId) ?? null; + + const handleNavigate = useCallback( + (url: string) => { + if (activeTab) { + navigateTab(projectId, activeTab.id, url); + } else { + addTab(projectId, url); + } + }, + [activeTab, addTab, navigateTab, projectId], + ); + + const handleOpenExternal = useCallback(() => { + if (!activeTab) return; + const api = readNativeApi(); + if (!api) return; + void api.shell.openExternal(activeTab.url).catch(() => undefined); + }, [activeTab]); + + if (browserState.tabs.length === 0) { + return ( +
+
+ Browser + +
+
+

No browser tabs open

+ +
+
+ ); + } + + return ( +
+ {/* Tab bar */} +
+
+ {browserState.tabs.map((tab) => ( + setActiveTab(projectId, tab.id)} + onClose={() => closeTab(projectId, tab.id)} + /> + ))} +
+ +
+ + {/* URL bar */} + {activeTab && ( + + )} + + {/* iframe area */} +
+ {browserState.tabs.map((tab) => ( +