add connected accounts (oauth) feature

This commit is contained in:
Ramnique Singh 2026-01-06 06:56:42 +05:30
parent 47ab50bfe7
commit dfe940d0ba
17 changed files with 1084 additions and 24 deletions

View file

@ -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">

View file

@ -666,7 +666,7 @@ export const PromptInput = ({
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
[usingProvider]
);

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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 };
}

View 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();
}