mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 11:52:38 +02:00
add connected accounts (oauth) feature
This commit is contained in:
parent
47ab50bfe7
commit
dfe940d0ba
17 changed files with 1084 additions and 24 deletions
|
|
@ -201,6 +201,9 @@ function buildTree(entries: DirEntry[]): TreeNode[] {
|
|||
}
|
||||
|
||||
function App() {
|
||||
// Sidebar view state
|
||||
const [activeSidebarView, setActiveSidebarView] = useState<'files' | 'accounts'>('files')
|
||||
|
||||
// File browser state
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
|
|
@ -577,6 +580,8 @@ function App() {
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
activeView={activeSidebarView}
|
||||
onViewChange={setActiveSidebarView}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 z-20 flex shrink-0 items-center gap-2 border-b p-4 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -666,7 +666,7 @@ export const PromptInput = ({
|
|||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
||||
|
||||
[usingProvider]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronRight, File, Folder } from "lucide-react"
|
||||
import { ChevronRight, File, Folder, Plug } from "lucide-react"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SidebarMenuSub,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ConnectedAccountsSidebar } from "@/components/connected-accounts-sidebar"
|
||||
|
||||
type TreeNode = {
|
||||
name: string
|
||||
|
|
@ -27,11 +28,15 @@ type TreeNode = {
|
|||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
type SidebarView = 'files' | 'accounts'
|
||||
|
||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: 'file' | 'dir') => void
|
||||
activeView: SidebarView
|
||||
onViewChange: (view: SidebarView) => void
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
|
|
@ -39,6 +44,8 @@ export function AppSidebar({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
activeView,
|
||||
onViewChange,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
const { setOpen } = useSidebar()
|
||||
|
|
@ -64,15 +71,30 @@ export function AppSidebar({
|
|||
<SidebarMenuButton
|
||||
tooltip="Files"
|
||||
onClick={() => {
|
||||
onViewChange('files')
|
||||
setOpen(true)
|
||||
}}
|
||||
isActive={true}
|
||||
isActive={activeView === 'files'}
|
||||
className="px-2.5 md:px-2"
|
||||
>
|
||||
<File />
|
||||
<span>Files</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Connected Accounts"
|
||||
onClick={() => {
|
||||
onViewChange('accounts')
|
||||
setOpen(true)
|
||||
}}
|
||||
isActive={activeView === 'accounts'}
|
||||
className="px-2.5 md:px-2"
|
||||
>
|
||||
<Plug />
|
||||
<span>Connected Accounts</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
|
@ -81,26 +103,30 @@ export function AppSidebar({
|
|||
|
||||
{/* This is the second sidebar */}
|
||||
{/* We disable collapsible and let it fill remaining space */}
|
||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
{activeView === 'files' ? (
|
||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
) : (
|
||||
<ConnectedAccountsSidebar />
|
||||
)}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Loader2, Plug } from "lucide-react"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth"
|
||||
|
||||
type ConnectedAccountsSidebarProps = React.ComponentProps<typeof Sidebar>
|
||||
|
||||
export function ConnectedAccountsSidebar({ ...props }: ConnectedAccountsSidebarProps) {
|
||||
const { providers, isLoading: providersLoading } = useAvailableProviders()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Connected Accounts</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{providersLoading ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<Loader2 className="animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : providers.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<span className="text-muted-foreground">No providers available</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
providers.map((provider) => (
|
||||
<ProviderItem key={provider} provider={provider} />
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderItem({ provider }: { provider: string }) {
|
||||
const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider)
|
||||
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center justify-between w-full gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Plug className="size-4 shrink-0" />
|
||||
<span className="truncate">{providerDisplayName}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={disconnect}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
disabled={isConnecting || isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
128
apps/x/apps/renderer/src/hooks/useOAuth.ts
Normal file
128
apps/x/apps/renderer/src/hooks/useOAuth.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
/**
|
||||
* Hook for managing OAuth connection state for a specific provider
|
||||
*/
|
||||
export function useOAuth(provider: string) {
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isConnecting, setIsConnecting] = useState<boolean>(false);
|
||||
|
||||
// Check connection status on mount and when provider changes
|
||||
useEffect(() => {
|
||||
checkConnection();
|
||||
}, [provider]);
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:is-connected', { provider });
|
||||
setIsConnected(result.isConnected);
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status:', error);
|
||||
setIsConnected(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider });
|
||||
if (result.success) {
|
||||
toast(`Successfully connected to ${provider}`, 'success');
|
||||
await checkConnection();
|
||||
} else {
|
||||
toast(result.error || `Failed to connect to ${provider}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
toast(`Failed to connect to ${provider}`, 'error');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [provider, checkConnection]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:disconnect', { provider });
|
||||
if (result.success) {
|
||||
toast(`Disconnected from ${provider}`, 'success');
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
toast(`Failed to disconnect from ${provider}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error);
|
||||
toast(`Failed to disconnect from ${provider}`, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isLoading,
|
||||
isConnecting,
|
||||
connect,
|
||||
disconnect,
|
||||
refresh: checkConnection,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get list of connected providers
|
||||
*/
|
||||
export function useConnectedProviders() {
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:get-connected-providers', null);
|
||||
setProviders(result.providers);
|
||||
} catch (error) {
|
||||
console.error('Failed to get connected providers:', error);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { providers, isLoading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get list of available providers
|
||||
*/
|
||||
export function useAvailableProviders() {
|
||||
const [providers, setProviders] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('oauth:list-providers', null);
|
||||
setProviders(result.providers);
|
||||
} catch (error) {
|
||||
console.error('Failed to get available providers:', error);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return { providers, isLoading };
|
||||
}
|
||||
|
||||
59
apps/x/apps/renderer/src/lib/toast.ts
Normal file
59
apps/x/apps/renderer/src/lib/toast.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Simple toast notification system
|
||||
*/
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
const listeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
export function toast(message: string, type: ToastType = 'info'): void {
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
toasts.push({ id, message, type });
|
||||
notifyListeners();
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
notifyListeners();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current toasts
|
||||
*/
|
||||
export function getToasts(): Toast[] {
|
||||
return [...toasts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to toast changes
|
||||
*/
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a toast by ID
|
||||
*/
|
||||
export function removeToast(id: string): void {
|
||||
toasts = toasts.filter(t => t.id !== id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue