Merge pull request #13 from refactoringhq/task/vault-from-github

feat: vault from GitHub — create or clone a vault from a GitHub repo
This commit is contained in:
Luca Rossi
2026-02-23 08:35:10 +01:00
committed by GitHub
17 changed files with 2503 additions and 83 deletions

View File

@@ -53,7 +53,7 @@ vi.mock('./mock-tauri', () => ({
if (cmd === 'get_modified_files') return []
if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || ''
if (cmd === 'get_file_history') return []
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null }
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null }
if (cmd === 'save_settings') return null
return null
}),

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -9,6 +9,7 @@ import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions, generateUntitledName } from './hooks/useNoteActions'
@@ -19,6 +20,7 @@ import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
import { useUpdater } from './hooks/useUpdater'
import { setApiKey } from './utils/ai-chat'
import type { SidebarSelection, GitCommit } from './types'
import type { VaultOption } from './components/StatusBar'
import './App.css'
// Type declaration for mock content storage
@@ -30,7 +32,7 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
const VAULTS = isTauri()
const DEFAULT_VAULTS: VaultOption[] = isTauri()
? [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
{ label: 'Laputa', path: '/Users/luca/Laputa' },
@@ -58,9 +60,13 @@ function App() {
const [showQuickOpen, setShowQuickOpen] = useState(false)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [vaultPath, setVaultPath] = useState(VAULTS[0].path)
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
const [showAIChat, setShowAIChat] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [showGitHubVault, setShowGitHubVault] = useState(false)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
const vault = useVaultLoader(vaultPath)
const { settings, saveSettings } = useSettings()
@@ -94,6 +100,15 @@ function App() {
notes.closeAllTabs()
}, [notes])
const handleVaultCloned = useCallback((path: string, label: string) => {
setExtraVaults(prev => {
if (prev.some(v => v.path === path)) return prev
return [...prev, { label, path }]
})
handleSwitchVault(path)
setToastMessage(`Vault "${label}" cloned and opened`)
}, [handleSwitchVault])
useEffect(() => {
if (!notes.activeTabPath) { setGitHistory([]); return }
vault.loadGitHistory(notes.activeTabPath).then(setGitHistory)
@@ -197,12 +212,19 @@ function App() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={VAULTS} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} />
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />
<CommitDialog open={showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={handleCommitPush} onClose={() => setShowCommitDialog(false)} />
<SettingsPanel open={showSettings} settings={settings} onSave={saveSettings} onClose={() => setShowSettings(false)} />
<GitHubVaultModal
open={showGitHubVault}
githubToken={settings.github_token}
onClose={() => setShowGitHubVault(false)}
onVaultCloned={handleVaultCloned}
onOpenSettings={() => { setShowGitHubVault(false); setShowSettings(true) }}
/>
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { GitHubVaultModal } from './GitHubVaultModal'
// Mock mockInvoke — the component uses tauriCall which calls mockInvoke in browser
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
const MOCK_REPOS = [
{ name: 'my-vault', full_name: 'user/my-vault', description: 'A personal vault', private: true, clone_url: 'https://github.com/user/my-vault.git', html_url: 'https://github.com/user/my-vault', updated_at: '2026-02-20T10:00:00Z' },
{ name: 'public-notes', full_name: 'user/public-notes', description: 'Public notes repo', private: false, clone_url: 'https://github.com/user/public-notes.git', html_url: 'https://github.com/user/public-notes', updated_at: '2026-02-19T10:00:00Z' },
]
describe('GitHubVaultModal', () => {
const onClose = vi.fn()
const onVaultCloned = vi.fn()
const onOpenSettings = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_list_repos') return MOCK_REPOS
if (cmd === 'github_create_repo') return MOCK_REPOS[0]
if (cmd === 'clone_repo') return 'Cloned successfully'
throw new Error(`Unknown command: ${cmd}`)
})
})
it('renders nothing when not open', () => {
const { container } = render(
<GitHubVaultModal open={false} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
})
it('shows connect prompt when no GitHub token', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
expect(screen.getByText(/Add your GitHub token in Settings/i)).toBeInTheDocument()
expect(screen.getByTestId('github-open-settings')).toBeInTheDocument()
})
it('opens settings when "Open Settings" clicked without token', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
fireEvent.click(screen.getByTestId('github-open-settings'))
expect(onOpenSettings).toHaveBeenCalled()
})
it('shows clone and create tabs when token is present', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
expect(screen.getByTestId('github-tab-clone')).toBeInTheDocument()
expect(screen.getByTestId('github-tab-create')).toBeInTheDocument()
})
it('loads and displays repos in clone tab', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
})
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
expect(screen.getByText('A personal vault')).toBeInTheDocument()
})
it('filters repos by search', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
})
fireEvent.change(screen.getByTestId('github-repo-search'), { target: { value: 'public' } })
expect(screen.queryByText('user/my-vault')).not.toBeInTheDocument()
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
})
it('selects a repo and auto-fills clone path', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
const pathInput = screen.getByTestId('github-clone-path') as HTMLInputElement
expect(pathInput.value).toBe('~/Vaults/my-vault')
})
it('clone button disabled without selection', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByTestId('github-clone-btn')).toBeInTheDocument()
})
expect(screen.getByTestId('github-clone-btn')).toBeDisabled()
})
it('calls clone_repo and onVaultCloned on clone', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
fireEvent.click(screen.getByTestId('github-clone-btn'))
await waitFor(() => {
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
})
})
it('has create tab trigger that is clickable', () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
const createTab = screen.getByTestId('github-tab-create')
expect(createTab).toBeInTheDocument()
expect(createTab).toHaveTextContent('Create New')
expect(createTab).not.toBeDisabled()
})
it('shows error when clone fails', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_list_repos') return MOCK_REPOS
if (cmd === 'clone_repo') throw new Error('Permission denied')
throw new Error(`Unknown: ${cmd}`)
})
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
fireEvent.click(screen.getByTestId('github-clone-btn'))
await waitFor(() => {
expect(screen.getByText(/Clone failed/i)).toBeInTheDocument()
})
expect(onVaultCloned).not.toHaveBeenCalled()
})
it('shows Private/Public badges on repos', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
await waitFor(() => {
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
})
expect(screen.getByText('Private')).toBeInTheDocument()
expect(screen.getByText('Public')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,436 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GithubRepo } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
interface GitHubVaultModalProps {
open: boolean
githubToken: string | null
onClose: () => void
onVaultCloned: (path: string, label: string) => void
onOpenSettings: () => void
}
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
function RepoListItem({ repo, selected, onSelect }: { repo: GithubRepo; selected: boolean; onSelect: () => void }) {
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
className={`flex flex-col gap-1 rounded-md px-3 py-2.5 cursor-pointer transition-colors ${
selected ? 'bg-accent' : 'hover:bg-accent/50'
}`}
data-testid={`repo-item-${repo.name}`}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{repo.full_name}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{repo.private ? 'Private' : 'Public'}
</Badge>
</div>
{repo.description && (
<span className="text-xs text-muted-foreground line-clamp-1">{repo.description}</span>
)}
</div>
)
}
function CloneTab({
repos,
loading,
search,
setSearch,
selectedRepo,
setSelectedRepo,
localPath,
setLocalPath,
onClone,
cloneStatus,
cloneError,
}: {
repos: GithubRepo[]
loading: boolean
search: string
setSearch: (v: string) => void
selectedRepo: GithubRepo | null
setSelectedRepo: (r: GithubRepo) => void
localPath: string
setLocalPath: (v: string) => void
onClone: () => void
cloneStatus: CloneStatus
cloneError: string | null
}) {
const filtered = repos.filter(r =>
r.full_name.toLowerCase().includes(search.toLowerCase()) ||
(r.description?.toLowerCase().includes(search.toLowerCase()) ?? false)
)
return (
<div className="flex flex-col gap-3 min-h-0">
<Input
placeholder="Search repos or paste URL..."
value={search}
onChange={e => setSearch(e.target.value)}
data-testid="github-repo-search"
/>
<div className="flex-1 overflow-y-auto border border-border rounded-md min-h-[180px] max-h-[240px]">
{loading ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Loading repositories...
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{search ? 'No repos match your search' : 'No repositories found'}
</div>
) : (
<div className="flex flex-col gap-0.5 p-1">
{filtered.map(repo => (
<RepoListItem
key={repo.full_name}
repo={repo}
selected={selectedRepo?.full_name === repo.full_name}
onSelect={() => setSelectedRepo(repo)}
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Clone to:</label>
<Input
value={localPath}
onChange={e => setLocalPath(e.target.value)}
placeholder="~/Vaults/repo-name"
data-testid="github-clone-path"
/>
</div>
{cloneError && (
<p className="text-xs text-destructive">{cloneError}</p>
)}
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
{filtered.length} repo{filtered.length !== 1 ? 's' : ''} found
</span>
<Button
onClick={onClone}
disabled={!selectedRepo || !localPath.trim() || cloneStatus === 'cloning'}
data-testid="github-clone-btn"
>
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
</Button>
</DialogFooter>
</div>
)
}
function CreateTab({
repoName,
setRepoName,
isPrivate,
setIsPrivate,
localPath,
setLocalPath,
onCreate,
cloneStatus,
cloneError,
}: {
repoName: string
setRepoName: (v: string) => void
isPrivate: boolean
setIsPrivate: (v: boolean) => void
localPath: string
setLocalPath: (v: string) => void
onCreate: () => void
cloneStatus: CloneStatus
cloneError: string | null
}) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Repository name</label>
<Input
value={repoName}
onChange={e => setRepoName(e.target.value)}
placeholder="my-vault"
data-testid="github-repo-name"
/>
{repoName && (
<span className="text-[11px] text-muted-foreground">
github.com/you/{repoName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-foreground">Visibility</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsPrivate(true)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
isPrivate
? 'border-ring bg-accent text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
data-testid="github-visibility-private"
>
Private
</button>
<button
type="button"
onClick={() => setIsPrivate(false)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
!isPrivate
? 'border-ring bg-accent text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
data-testid="github-visibility-public"
>
Public
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Clone to:</label>
<Input
value={localPath}
onChange={e => setLocalPath(e.target.value)}
placeholder="~/Vaults/my-vault"
data-testid="github-create-path"
/>
</div>
{cloneError && (
<p className="text-xs text-destructive">{cloneError}</p>
)}
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
<Button
onClick={onCreate}
disabled={!repoName.trim() || !localPath.trim() || cloneStatus === 'cloning'}
data-testid="github-create-btn"
>
{cloneStatus === 'cloning' ? 'Creating...' : 'Create & Clone'}
</Button>
</DialogFooter>
</div>
)
}
function CloningProgress({ repoName }: { repoName: string }) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-8" data-testid="clone-progress">
<div className="h-10 w-10 rounded-full border-[3px] border-ring border-t-transparent animate-spin" />
<p className="text-sm font-medium text-foreground">Cloning {repoName}...</p>
<p className="text-xs text-muted-foreground">This may take a moment for large repositories</p>
</div>
)
}
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
const [tab, setTab] = useState('clone')
const [repos, setRepos] = useState<GithubRepo[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [selectedRepo, setSelectedRepo] = useState<GithubRepo | null>(null)
const [clonePath, setClonePath] = useState('')
const [repoName, setRepoName] = useState('')
const [isPrivate, setIsPrivate] = useState(true)
const [createPath, setCreatePath] = useState('')
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
const [cloneError, setCloneError] = useState<string | null>(null)
const loadedRef = useRef(false)
const loadRepos = useCallback(async () => {
if (!githubToken) return
setLoading(true)
try {
const result = await tauriCall<GithubRepo[]>('github_list_repos', { token: githubToken })
setRepos(result)
} catch (err) {
console.error('Failed to load GitHub repos:', err)
setCloneError(`Failed to load repos: ${err}`)
} finally {
setLoading(false)
}
}, [githubToken])
useEffect(() => {
if (open && githubToken && !loadedRef.current) {
loadedRef.current = true
loadRepos()
}
if (!open) {
loadedRef.current = false
}
}, [open, githubToken, loadRepos])
// Auto-fill clone path when repo selected
useEffect(() => {
if (selectedRepo) {
setClonePath(`~/Vaults/${selectedRepo.name}`)
}
}, [selectedRepo])
// Auto-fill create path when name changes
useEffect(() => {
if (repoName) {
setCreatePath(`~/Vaults/${repoName}`)
}
}, [repoName])
const resetState = useCallback(() => {
setTab('clone')
setSearch('')
setSelectedRepo(null)
setClonePath('')
setRepoName('')
setIsPrivate(true)
setCreatePath('')
setCloneStatus('idle')
setCloneError(null)
}, [])
const handleClose = useCallback(() => {
resetState()
onClose()
}, [onClose, resetState])
const handleClone = useCallback(async () => {
if (!selectedRepo || !clonePath.trim() || !githubToken) return
setCloneStatus('cloning')
setCloneError(null)
try {
await tauriCall<string>('clone_repo', {
url: selectedRepo.clone_url,
token: githubToken,
localPath: clonePath.trim(),
})
setCloneStatus('success')
onVaultCloned(clonePath.trim(), selectedRepo.name)
handleClose()
} catch (err) {
setCloneStatus('error')
setCloneError(`Clone failed: ${err}`)
}
}, [selectedRepo, clonePath, githubToken, onVaultCloned, handleClose])
const handleCreate = useCallback(async () => {
if (!repoName.trim() || !createPath.trim() || !githubToken) return
setCloneStatus('cloning')
setCloneError(null)
try {
const repo = await tauriCall<GithubRepo>('github_create_repo', {
token: githubToken,
name: repoName.trim(),
private: isPrivate,
})
await tauriCall<string>('clone_repo', {
url: repo.clone_url,
token: githubToken,
localPath: createPath.trim(),
})
setCloneStatus('success')
onVaultCloned(createPath.trim(), repo.name)
handleClose()
} catch (err) {
setCloneStatus('error')
setCloneError(`${err}`)
}
}, [repoName, createPath, githubToken, isPrivate, onVaultCloned, handleClose])
if (!githubToken) {
return (
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
<DialogContent className="sm:max-w-[480px]" data-testid="github-vault-modal">
<DialogHeader>
<DialogTitle>Connect GitHub Repo</DialogTitle>
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-sm text-muted-foreground text-center">
You need to connect your GitHub account first. Add your GitHub token in Settings.
</p>
<Button
onClick={() => { handleClose(); onOpenSettings() }}
data-testid="github-open-settings"
>
Open Settings
</Button>
</div>
</DialogContent>
</Dialog>
)
}
const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName
return (
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
<DialogContent className="sm:max-w-[540px]" data-testid="github-vault-modal">
<DialogHeader>
<DialogTitle>Connect GitHub Repo</DialogTitle>
<DialogDescription>Clone an existing repo or create a new one as a vault.</DialogDescription>
</DialogHeader>
{cloneStatus === 'cloning' ? (
<CloningProgress repoName={cloningRepoName} />
) : (
<Tabs value={tab} onValueChange={setTab}>
<TabsList variant="line">
<TabsTrigger value="clone" data-testid="github-tab-clone">Clone Existing</TabsTrigger>
<TabsTrigger value="create" data-testid="github-tab-create">Create New</TabsTrigger>
</TabsList>
<TabsContent value="clone">
<CloneTab
repos={repos}
loading={loading}
search={search}
setSearch={setSearch}
selectedRepo={selectedRepo}
setSelectedRepo={setSelectedRepo}
localPath={clonePath}
setLocalPath={setClonePath}
onClone={handleClone}
cloneStatus={cloneStatus}
cloneError={cloneError}
/>
</TabsContent>
<TabsContent value="create">
<CreateTab
repoName={repoName}
setRepoName={setRepoName}
isPrivate={isPrivate}
setIsPrivate={setIsPrivate}
localPath={createPath}
setLocalPath={setCreatePath}
onCreate={handleCreate}
cloneStatus={cloneStatus}
cloneError={cloneError}
/>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -7,12 +7,14 @@ const emptySettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
}
const populatedSettings: Settings = {
anthropic_key: 'sk-ant-api03-test123',
openai_key: 'sk-openai-test456',
google_key: null,
github_token: null,
}
describe('SettingsPanel', () => {
@@ -74,6 +76,7 @@ describe('SettingsPanel', () => {
anthropic_key: 'sk-ant-test',
openai_key: null,
google_key: null,
github_token: null,
})
expect(onClose).toHaveBeenCalled()
})
@@ -92,6 +95,7 @@ describe('SettingsPanel', () => {
anthropic_key: null,
openai_key: 'sk-openai-test456',
google_key: null,
github_token: null,
})
})
@@ -131,6 +135,7 @@ describe('SettingsPanel', () => {
anthropic_key: 'sk-ant-test',
openai_key: null,
google_key: null,
github_token: null,
})
})

View File

@@ -73,12 +73,14 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
const [anthropicKey, setAnthropicKey] = useState('')
const [openaiKey, setOpenaiKey] = useState('')
const [googleKey, setGoogleKey] = useState('')
const [githubToken, setGithubToken] = useState('')
useEffect(() => {
if (open) {
setAnthropicKey(settings.anthropic_key ?? '')
setOpenaiKey(settings.openai_key ?? '')
setGoogleKey(settings.google_key ?? '')
setGithubToken(settings.github_token ?? '')
}
}, [open, settings])
@@ -89,6 +91,7 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
anthropic_key: anthropicKey.trim() || null,
openai_key: openaiKey.trim() || null,
google_key: googleKey.trim() || null,
github_token: githubToken.trim() || null,
}
onSave(trimmed)
onClose()
@@ -164,6 +167,25 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
onChange={setGoogleKey}
onClear={() => setGoogleKey('')}
/>
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>
GitHub
</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Personal access token for cloning and syncing vaults with GitHub.
</div>
</div>
<KeyField
label="GitHub Token"
placeholder="ghp_... or gho_..."
value={githubToken}
onChange={setGithubToken}
onClear={() => setGithubToken('')}
/>
</div>
{/* Footer */}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check } from 'lucide-react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github } from 'lucide-react'
export interface VaultOption {
label: string
@@ -12,6 +12,8 @@ interface StatusBarProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onConnectGitHub?: () => void
hasGitHub?: boolean
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
@@ -32,7 +34,7 @@ function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isAc
)
}
function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void }) {
function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((v) => v.path === vaultPath)
@@ -53,8 +55,28 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]
{activeVault?.label ?? 'Vault'}
</span>
{open && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
{onConnectGitHub && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<div
role="button"
onClick={() => { onConnectGitHub(); setOpen(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', background: 'transparent',
color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="vault-menu-connect-github"
>
<Github size={12} />
Connect GitHub repo
</div>
</>
)}
</div>
)}
</div>
@@ -65,11 +87,11 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings }: StatusBarProps) {
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onConnectGitHub, hasGitHub }: StatusBarProps) {
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} />
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
<span style={SEP_STYLE}>|</span>

View File

@@ -7,12 +7,14 @@ const defaultSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
}
const savedSettings: Settings = {
anthropic_key: 'sk-ant-test123',
openai_key: null,
google_key: 'AIza-test',
github_token: null,
}
let mockSettingsStore: Settings = { ...defaultSettings }
@@ -71,6 +73,7 @@ describe('useSettings', () => {
anthropic_key: 'sk-ant-new',
openai_key: 'sk-openai-new',
google_key: null,
github_token: null,
}
await act(async () => {

View File

@@ -11,6 +11,7 @@ const EMPTY_SETTINGS: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
}
export function useSettings() {

View File

@@ -1645,6 +1645,7 @@ let mockSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: 'gho_mock_token_for_testing',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
@@ -1693,6 +1694,7 @@ const mockHandlers: Record<string, (args: any) => any> = {
anthropic_key: s.anthropic_key?.trim() || null,
openai_key: s.openai_key?.trim() || null,
google_key: s.google_key?.trim() || null,
github_token: s.github_token?.trim() || null,
}
return null
},
@@ -1732,6 +1734,23 @@ const mockHandlers: Record<string, (args: any) => any> = {
}
return { new_path: newPath, updated_files: updatedFiles }
},
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
{ name: 'dotfiles', full_name: 'lucaong/dotfiles', description: 'My macOS dotfiles and config', private: false, clone_url: 'https://github.com/lucaong/dotfiles.git', html_url: 'https://github.com/lucaong/dotfiles', updated_at: '2026-01-15T08:00:00Z' },
{ name: 'notes-archive', full_name: 'lucaong/notes-archive', description: 'Archived notes from 2024', private: true, clone_url: 'https://github.com/lucaong/notes-archive.git', html_url: 'https://github.com/lucaong/notes-archive', updated_at: '2025-12-01T12:00:00Z' },
{ name: 'obsidian-vault', full_name: 'lucaong/obsidian-vault', description: null, private: true, clone_url: 'https://github.com/lucaong/obsidian-vault.git', html_url: 'https://github.com/lucaong/obsidian-vault', updated_at: '2025-11-05T09:00:00Z' },
],
github_create_repo: (args: { name: string; private: boolean }) => ({
name: args.name,
full_name: `lucaong/${args.name}`,
description: 'Laputa vault',
private: args.private,
clone_url: `https://github.com/lucaong/${args.name}.git`,
html_url: `https://github.com/lucaong/${args.name}`,
updated_at: new Date().toISOString(),
}),
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
}
export function isTauri(): boolean {

View File

@@ -44,6 +44,17 @@ export interface Settings {
anthropic_key: string | null
openai_key: string | null
google_key: string | null
github_token: string | null
}
export interface GithubRepo {
name: string
full_name: string
description: string | null
private: boolean
clone_url: string
html_url: string
updated_at: string | null
}
export type SidebarSelection =