mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-18 14:39:55 +02:00
Add initial API documentation for Linsa API endpoints to docs/api.md
This commit is contained in:
109
packages/desktop/src/features/folders/model/atoms.ts
Normal file
109
packages/desktop/src/features/folders/model/atoms.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { atom, computed } from "@reatom/core"
|
||||
import { reatomJazz } from "@/shared/lib/reatom/jazz"
|
||||
import { jazzContext } from "@/shared/lib/jazz/context"
|
||||
import {
|
||||
AppAccount,
|
||||
AppRoot,
|
||||
CodeFolder,
|
||||
CodeFoldersList,
|
||||
} from "./schema"
|
||||
|
||||
// Reatom factory for CodeFolder
|
||||
export const reatomCodeFolder = reatomJazz({
|
||||
schema: CodeFolder,
|
||||
name: "codeFolder",
|
||||
create: ({ loaded }) => ({
|
||||
path: loaded.path,
|
||||
addedAt: loaded.addedAt,
|
||||
}),
|
||||
})
|
||||
export type CodeFolderModel = ReturnType<typeof reatomCodeFolder>
|
||||
|
||||
// Reatom factory for CodeFoldersList
|
||||
export const reatomCodeFoldersList = reatomJazz({
|
||||
schema: CodeFoldersList,
|
||||
name: "codeFoldersList",
|
||||
create: ({ loaded }) => {
|
||||
const items = [...loaded.$jazz.refs].map((ref) => reatomCodeFolder(ref.id))
|
||||
return { items }
|
||||
},
|
||||
})
|
||||
|
||||
// Reatom factory for AppRoot
|
||||
export const reatomAppRoot = reatomJazz({
|
||||
schema: AppRoot,
|
||||
name: "appRoot",
|
||||
resolve: { folders: { $each: true } },
|
||||
create: ({ loaded }) => {
|
||||
const foldersId = loaded.$jazz.refs.folders?.id
|
||||
return {
|
||||
folders: foldersId ? reatomCodeFoldersList(foldersId) : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Reatom factory for AppAccount
|
||||
export const reatomAppAccount = reatomJazz({
|
||||
schema: AppAccount,
|
||||
name: "appAccount",
|
||||
resolve: { root: { folders: { $each: true } } },
|
||||
create: ({ loaded }) => {
|
||||
const rootId = loaded.$jazz.refs.root?.id
|
||||
return {
|
||||
root: rootId ? reatomAppRoot(rootId) : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Current account atom - derived from Jazz context
|
||||
export const currentAccount = computed(() => {
|
||||
const ctx = jazzContext()
|
||||
const accountId = ctx.current().me.$jazz.id
|
||||
return reatomAppAccount(accountId)()
|
||||
}, "currentAccount")
|
||||
|
||||
// Convenience atom for folders list
|
||||
export const codeFolders = computed(() => {
|
||||
const account = currentAccount()
|
||||
const root = account.root?.()
|
||||
const folders = root?.folders?.()
|
||||
return folders?.items ?? []
|
||||
}, "codeFolders")
|
||||
|
||||
// Actions
|
||||
export const addFolderAction = atom(null, (get, set, path: string) => {
|
||||
const ctx = jazzContext()
|
||||
const me = ctx.current().me as co.loaded<typeof AppAccount>
|
||||
|
||||
if (!me.root?.folders) return
|
||||
|
||||
// Check if already exists
|
||||
const existing = [...me.root.folders].find((f) => f?.path === path)
|
||||
if (existing) return
|
||||
|
||||
// Create new folder entry
|
||||
const folder = CodeFolder.create({
|
||||
path,
|
||||
addedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Add to list
|
||||
me.root.folders.$jazz.push(folder)
|
||||
}, "addFolderAction")
|
||||
|
||||
export const removeFolderAction = atom(null, (get, set, path: string) => {
|
||||
const ctx = jazzContext()
|
||||
const me = ctx.current().me as co.loaded<typeof AppAccount>
|
||||
|
||||
if (!me.root?.folders) return
|
||||
|
||||
// Find index
|
||||
const index = [...me.root.folders].findIndex((f) => f?.path === path)
|
||||
if (index === -1) return
|
||||
|
||||
// Remove from list
|
||||
me.root.folders.$jazz.splice(index, 1)
|
||||
}, "removeFolderAction")
|
||||
|
||||
// Import for co type
|
||||
import { co } from "jazz-tools"
|
||||
65
packages/desktop/src/features/folders/model/schema.ts
Normal file
65
packages/desktop/src/features/folders/model/schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { co, z, setDefaultSchemaPermissions } from "jazz-tools"
|
||||
|
||||
setDefaultSchemaPermissions({
|
||||
onInlineCreate: "sameAsContainer",
|
||||
})
|
||||
|
||||
// A single code folder entry
|
||||
export const CodeFolder = co.map({
|
||||
path: z.string(),
|
||||
addedAt: z.number(),
|
||||
})
|
||||
export type CodeFolder = co.loaded<typeof CodeFolder>
|
||||
|
||||
// List of code folders
|
||||
export const CodeFoldersList = co.list(CodeFolder)
|
||||
export type CodeFoldersList = co.loaded<typeof CodeFoldersList>
|
||||
|
||||
// App root data
|
||||
export const AppRoot = co.map({
|
||||
folders: CodeFoldersList,
|
||||
})
|
||||
export type AppRoot = co.loaded<typeof AppRoot>
|
||||
|
||||
// Account profile (minimal)
|
||||
export const AppProfile = co.profile({
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
// Main account schema with migration
|
||||
export const AppAccount = co
|
||||
.account({
|
||||
root: AppRoot,
|
||||
profile: AppProfile,
|
||||
})
|
||||
.withMigration(async (account) => {
|
||||
// Initialize root if missing
|
||||
if (!account.$jazz.has("root")) {
|
||||
account.$jazz.set(
|
||||
"root",
|
||||
AppRoot.create({
|
||||
folders: CodeFoldersList.create([]),
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// Ensure folders list exists
|
||||
const { root } = await account.$jazz.ensureLoaded({
|
||||
resolve: { root: true },
|
||||
})
|
||||
if (!root.$jazz.has("folders")) {
|
||||
root.$jazz.set("folders", CodeFoldersList.create([]))
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize profile if missing
|
||||
if (!account.$jazz.has("profile")) {
|
||||
account.$jazz.set(
|
||||
"profile",
|
||||
AppProfile.create({
|
||||
name: "Desktop User",
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export type AppAccount = co.loaded<typeof AppAccount>
|
||||
30
packages/desktop/src/features/repos/model/atoms.ts
Normal file
30
packages/desktop/src/features/repos/model/atoms.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { atom, action } from "@reatom/core"
|
||||
|
||||
export interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
// Local state for scanned repos (not synced via Jazz)
|
||||
export const reposAtom = atom<GitRepo[]>([], "repos")
|
||||
|
||||
// Scanning state
|
||||
export const isScanningAtom = atom(false, "isScanning")
|
||||
|
||||
// Scan folders for git repos
|
||||
export const scanReposAction = action(async (ctx, folders: string[]) => {
|
||||
if (folders.length === 0) {
|
||||
reposAtom(ctx, [])
|
||||
return
|
||||
}
|
||||
|
||||
isScanningAtom(ctx, true)
|
||||
|
||||
try {
|
||||
const repos = await window.electronAPI.scanRepos(folders)
|
||||
reposAtom(ctx, repos)
|
||||
} finally {
|
||||
isScanningAtom(ctx, false)
|
||||
}
|
||||
}, "scanRepos")
|
||||
157
packages/desktop/src/main/index.ts
Normal file
157
packages/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
async function findGitRepos(dir: string, maxDepth = 4): Promise<GitRepo[]> {
|
||||
const repos: GitRepo[] = [];
|
||||
|
||||
async function scan(currentPath: string, depth: number): Promise<void> {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
try {
|
||||
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") && entry.name !== ".git") continue;
|
||||
if (entry.name === "node_modules") continue;
|
||||
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
|
||||
if (entry.name === ".git") {
|
||||
const repoPath = currentPath;
|
||||
const stats = await stat(fullPath);
|
||||
repos.push({
|
||||
name: basename(repoPath),
|
||||
path: repoPath,
|
||||
lastModified: stats.mtimeMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await scan(fullPath, depth + 1);
|
||||
}
|
||||
} catch {
|
||||
// Permission denied or other error - skip
|
||||
}
|
||||
}
|
||||
|
||||
await scan(dir, 0);
|
||||
return repos;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const preloadJs = join(__dirname, "../preload/index.js");
|
||||
const preloadCjs = join(__dirname, "../preload/index.cjs");
|
||||
const preloadMjs = join(__dirname, "../preload/index.mjs");
|
||||
const preloadPath = [preloadJs, preloadCjs, preloadMjs].find((p) =>
|
||||
existsSync(p),
|
||||
);
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
titleBarStyle: "hiddenInset",
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http")) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
ipcMain.handle("dialog:pick-folder", async (): Promise<string | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select code folder",
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("repos:scan", async (_event, folders: string[]): Promise<GitRepo[]> => {
|
||||
const allRepos: GitRepo[] = [];
|
||||
|
||||
for (const folder of folders) {
|
||||
const repos = await findGitRepos(folder);
|
||||
allRepos.push(...repos);
|
||||
}
|
||||
|
||||
allRepos.sort((a, b) => b.lastModified - a.lastModified);
|
||||
|
||||
const seen = new Set<string>();
|
||||
return allRepos.filter((repo) => {
|
||||
if (seen.has(repo.path)) return false;
|
||||
seen.add(repo.path);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:show-in-folder", async (_event, path: string) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:open-in-editor", async (_event, path: string) => {
|
||||
const { exec } = await import("node:child_process");
|
||||
exec(`code "${path}"`);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:open-in-terminal", async (_event, path: string) => {
|
||||
const { exec } = await import("node:child_process");
|
||||
if (process.platform === "darwin") {
|
||||
exec(`open -a Terminal "${path}"`);
|
||||
} else if (process.platform === "win32") {
|
||||
exec(`start cmd /K "cd /d ${path}"`);
|
||||
} else {
|
||||
exec(`x-terminal-emulator --working-directory="${path}"`);
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
15
packages/desktop/src/preload/index.ts
Normal file
15
packages/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
export interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
pickFolder: () => ipcRenderer.invoke("dialog:pick-folder") as Promise<string | null>,
|
||||
scanRepos: (folders: string[]) => ipcRenderer.invoke("repos:scan", folders) as Promise<GitRepo[]>,
|
||||
showInFolder: (path: string) => ipcRenderer.invoke("shell:show-in-folder", path),
|
||||
openInEditor: (path: string) => ipcRenderer.invoke("shell:open-in-editor", path),
|
||||
openInTerminal: (path: string) => ipcRenderer.invoke("shell:open-in-terminal", path),
|
||||
});
|
||||
392
packages/desktop/src/renderer/App.tsx
Normal file
392
packages/desktop/src/renderer/App.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useAccount } from "jazz-tools/react"
|
||||
import { AppAccount, CodeFolder } from "../features/folders/model/schema"
|
||||
|
||||
interface Command {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
export function App() {
|
||||
if (!window.electronAPI) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Electron preload bridge is missing.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const me = useAccount(AppAccount, {
|
||||
resolve: { root: { folders: { $each: { $onError: "catch" } } } },
|
||||
})
|
||||
|
||||
// Local state for repos (not synced via Jazz)
|
||||
const [repos, setRepos] = useState<GitRepo[]>([])
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [manualPath, setManualPath] = useState("")
|
||||
const [pathError, setPathError] = useState<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const [showPalette, setShowPalette] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Get folders from Jazz (safely handle loading state)
|
||||
const folders = me?.root?.folders ?? []
|
||||
const folderPaths = [...folders].filter(Boolean).map((f) => f!.path)
|
||||
|
||||
// Filter repos by search
|
||||
const filteredRepos = repos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
repo.path.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// Scan folders for repos
|
||||
const scanRepos = useCallback(async () => {
|
||||
if (folderPaths.length === 0) {
|
||||
setRepos([])
|
||||
return
|
||||
}
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos(folderPaths)
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [folderPaths.join(","), setRepos, setIsScanning])
|
||||
|
||||
// Scan when folders change
|
||||
useEffect(() => {
|
||||
if (folderPaths.length > 0) {
|
||||
scanRepos()
|
||||
} else {
|
||||
setRepos([])
|
||||
}
|
||||
}, [folderPaths.join(",")])
|
||||
|
||||
// Add a folder (saves to Jazz)
|
||||
const addFolder = useCallback(async () => {
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const path = await window.electronAPI.pickFolder()
|
||||
if (!path) return
|
||||
if (folderPaths.includes(path)) return
|
||||
|
||||
// Create folder entry and add to Jazz
|
||||
const folder = CodeFolder.create({ path, addedAt: Date.now() })
|
||||
me.root.folders.$jazz.push(folder)
|
||||
|
||||
setShowPalette(false)
|
||||
}, [me?.root?.folders, folderPaths])
|
||||
|
||||
const addManualFolder = useCallback(() => {
|
||||
setPathError(null)
|
||||
const trimmed = manualPath.trim()
|
||||
if (!trimmed) {
|
||||
setPathError("Enter a folder path")
|
||||
return
|
||||
}
|
||||
if (folderPaths.includes(trimmed)) {
|
||||
setPathError("Already added")
|
||||
return
|
||||
}
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const folder = CodeFolder.create({ path: trimmed, addedAt: Date.now() })
|
||||
me.root.folders.$jazz.push(folder)
|
||||
setManualPath("")
|
||||
}, [manualPath, folderPaths, me?.root?.folders])
|
||||
|
||||
// Remove a folder (removes from Jazz)
|
||||
const removeFolder = useCallback(
|
||||
(path: string) => {
|
||||
if (!me?.root?.folders) return
|
||||
|
||||
const idx = [...folders].findIndex((f) => f?.path === path)
|
||||
if (idx !== -1) {
|
||||
me.root.folders.$jazz.splice(idx, 1)
|
||||
}
|
||||
},
|
||||
[folders, me?.root?.folders]
|
||||
)
|
||||
|
||||
// Open repo in editor
|
||||
const openInEditor = useCallback((repo: GitRepo) => {
|
||||
window.electronAPI.openInEditor(repo.path)
|
||||
setShowPalette(false)
|
||||
}, [])
|
||||
|
||||
// Commands for palette
|
||||
const commands: Command[] = [
|
||||
{ id: "add-folder", label: "Add code folder", shortcut: "A", action: addFolder },
|
||||
{ id: "refresh", label: "Refresh repos", shortcut: "R", action: scanRepos },
|
||||
]
|
||||
|
||||
// When palette is open with search, show repos as commands
|
||||
const paletteItems =
|
||||
showPalette && search
|
||||
? filteredRepos.map((repo) => ({
|
||||
id: repo.path,
|
||||
label: repo.name,
|
||||
shortcut: undefined,
|
||||
action: () => openInEditor(repo),
|
||||
}))
|
||||
: commands
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setShowPalette((prev) => !prev)
|
||||
setSearch("")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
if (e.key === "Escape" && showPalette) {
|
||||
setShowPalette(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [showPalette])
|
||||
|
||||
// Focus input when palette opens
|
||||
useEffect(() => {
|
||||
if (showPalette && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [showPalette])
|
||||
|
||||
// Reset selected index when search changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search])
|
||||
|
||||
const handlePaletteKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, paletteItems.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter" && paletteItems.length > 0) {
|
||||
paletteItems[selectedIndex]?.action()
|
||||
}
|
||||
}
|
||||
|
||||
const timeAgo = (ms: number) => {
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Loading account...</p>
|
||||
<button
|
||||
className="secondary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => {
|
||||
indexedDB.deleteDatabase("cojson")
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
Reset local session
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Command Palette */}
|
||||
{showPalette && (
|
||||
<div className="palette-overlay" onClick={() => setShowPalette(false)}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="palette-input"
|
||||
placeholder="Search repos or type a command..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handlePaletteKeyDown}
|
||||
/>
|
||||
<div className="palette-commands">
|
||||
{paletteItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`palette-command ${index === selectedIndex ? "selected" : ""}`}
|
||||
onClick={item.action}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && <kbd className="palette-shortcut">{item.shortcut}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
{paletteItems.length === 0 && <div className="palette-empty">No results</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<div className="header-drag" />
|
||||
<h1>Repos</h1>
|
||||
<div className="header-right">
|
||||
<span className="sync-status sync-connected">Synced</span>
|
||||
<button className="header-btn" onClick={() => setShowPalette(true)}>
|
||||
<kbd>Cmd+K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<section className="card">
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<p className="eyebrow">Parent folders</p>
|
||||
<h2>Where to look for repos</h2>
|
||||
<p className="muted">Add one or more parent folders. We’ll scan them deeply for git repos.</p>
|
||||
</div>
|
||||
<div className="pill">{folderPaths.length} folders</div>
|
||||
</div>
|
||||
|
||||
<div className="folder-inputs">
|
||||
<div className="input-stack">
|
||||
<label className="label">Add by path</label>
|
||||
<div className="input-row">
|
||||
<input
|
||||
value={manualPath}
|
||||
onChange={(e) => setManualPath(e.target.value)}
|
||||
placeholder="/Users/you/code"
|
||||
className={`text-input ${pathError ? "has-error" : ""}`}
|
||||
/>
|
||||
<button className="primary-btn" onClick={addManualFolder}>
|
||||
Add
|
||||
</button>
|
||||
<button className="ghost-btn" onClick={addFolder}>
|
||||
Pick from Finder
|
||||
</button>
|
||||
</div>
|
||||
{pathError && <p className="error-text">{pathError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{folderPaths.length === 0 ? (
|
||||
<div className="empty-inline">
|
||||
<p>No folders yet. Add at least one to start scanning.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chip-grid">
|
||||
{folderPaths.map((path) => (
|
||||
<div key={path} className="chip">
|
||||
<span className="chip-label" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<button className="chip-remove" onClick={() => removeFolder(path)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="secondary-btn"
|
||||
onClick={scanRepos}
|
||||
disabled={isScanning || folderPaths.length === 0}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Refresh now"}
|
||||
</button>
|
||||
<p className="muted">Deep git parsing will be added next.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{repos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{folderPaths.length === 0 ? (
|
||||
<>
|
||||
<h2>No folders configured</h2>
|
||||
<p>Add folders where you keep your code to get started.</p>
|
||||
<button className="primary-btn" onClick={addFolder}>
|
||||
Add folder
|
||||
</button>
|
||||
</>
|
||||
) : isScanning ? (
|
||||
<>
|
||||
<div className="spinner" />
|
||||
<p>Scanning for repos...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>No repos found</h2>
|
||||
<p>No git repositories found in the configured folders.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="repo-list">
|
||||
{repos.map((repo) => (
|
||||
<li key={repo.path} className="repo-item">
|
||||
<div className="repo-info" onClick={() => openInEditor(repo)}>
|
||||
<span className="repo-name">{repo.name}</span>
|
||||
<span className="repo-path">{repo.path}</span>
|
||||
</div>
|
||||
<div className="repo-meta">
|
||||
<span className="repo-time">{timeAgo(repo.lastModified)}</span>
|
||||
<div className="repo-actions">
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.openInEditor(repo.path)}
|
||||
title="Open in VS Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.openInTerminal(repo.path)}
|
||||
title="Open in Terminal"
|
||||
>
|
||||
Term
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => window.electronAPI.showInFolder(repo.path)}
|
||||
title="Show in Finder"
|
||||
>
|
||||
Finder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
294
packages/desktop/src/renderer/AppSimple.tsx
Normal file
294
packages/desktop/src/renderer/AppSimple.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useAtom } from "@reatom/react"
|
||||
import { atom } from "@reatom/core"
|
||||
|
||||
interface Command {
|
||||
id: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface GitRepo {
|
||||
name: string
|
||||
path: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
// Reatom atoms for state
|
||||
const foldersAtom = atom<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("repo-folders")
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, "folders")
|
||||
|
||||
const reposAtom = atom<GitRepo[]>([], "repos")
|
||||
const isScanningAtom = atom(false, "isScanning")
|
||||
|
||||
export function App() {
|
||||
// Reatom state
|
||||
const [folders, setFolders] = useAtom(foldersAtom)
|
||||
const [repos, setRepos] = useAtom(reposAtom)
|
||||
const [isScanning, setIsScanning] = useAtom(isScanningAtom)
|
||||
|
||||
// UI state
|
||||
const [showPalette, setShowPalette] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Save folders to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("repo-folders", JSON.stringify(folders))
|
||||
}, [folders])
|
||||
|
||||
// Filter repos by search
|
||||
const filteredRepos = repos.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
repo.path.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// Scan folders for repos
|
||||
const scanRepos = useCallback(async () => {
|
||||
if (folders.length === 0) {
|
||||
setRepos([])
|
||||
return
|
||||
}
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos(folders)
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [folders, setRepos, setIsScanning])
|
||||
|
||||
// Initial scan
|
||||
useEffect(() => {
|
||||
if (folders.length > 0) {
|
||||
scanRepos()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add a folder
|
||||
const addFolder = useCallback(async () => {
|
||||
const path = await window.electronAPI.pickFolder()
|
||||
if (!path) return
|
||||
if (folders.includes(path)) return
|
||||
setFolders([...folders, path])
|
||||
setShowPalette(false)
|
||||
// Scan after adding
|
||||
setTimeout(async () => {
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const found = await window.electronAPI.scanRepos([...folders, path])
|
||||
setRepos(found)
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, 100)
|
||||
}, [folders, setFolders, setRepos, setIsScanning])
|
||||
|
||||
// Remove a folder
|
||||
const removeFolder = useCallback(
|
||||
(path: string) => {
|
||||
const newFolders = folders.filter((f) => f !== path)
|
||||
setFolders(newFolders)
|
||||
if (newFolders.length > 0) {
|
||||
window.electronAPI.scanRepos(newFolders).then(setRepos)
|
||||
} else {
|
||||
setRepos([])
|
||||
}
|
||||
},
|
||||
[folders, setFolders, setRepos]
|
||||
)
|
||||
|
||||
// Open repo in editor
|
||||
const openInEditor = useCallback((repo: GitRepo) => {
|
||||
window.electronAPI.openInEditor(repo.path)
|
||||
setShowPalette(false)
|
||||
}, [])
|
||||
|
||||
// Commands
|
||||
const commands: Command[] = [
|
||||
{ id: "add-folder", label: "Add code folder", shortcut: "A", action: addFolder },
|
||||
{ id: "refresh", label: "Refresh repos", shortcut: "R", action: scanRepos },
|
||||
]
|
||||
|
||||
const paletteItems =
|
||||
showPalette && search
|
||||
? filteredRepos.map((repo) => ({
|
||||
id: repo.path,
|
||||
label: repo.name,
|
||||
shortcut: undefined,
|
||||
action: () => openInEditor(repo),
|
||||
}))
|
||||
: commands
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setShowPalette((prev) => !prev)
|
||||
setSearch("")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
if (e.key === "Escape" && showPalette) {
|
||||
setShowPalette(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [showPalette])
|
||||
|
||||
useEffect(() => {
|
||||
if (showPalette && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [showPalette])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search])
|
||||
|
||||
const handlePaletteKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, paletteItems.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter" && paletteItems.length > 0) {
|
||||
paletteItems[selectedIndex]?.action()
|
||||
}
|
||||
}
|
||||
|
||||
const timeAgo = (ms: number) => {
|
||||
const seconds = Math.floor((Date.now() - ms) / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{showPalette && (
|
||||
<div className="palette-overlay" onClick={() => setShowPalette(false)}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="palette-input"
|
||||
placeholder="Search repos or type a command..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handlePaletteKeyDown}
|
||||
/>
|
||||
<div className="palette-commands">
|
||||
{paletteItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`palette-command ${index === selectedIndex ? "selected" : ""}`}
|
||||
onClick={item.action}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && <kbd className="palette-shortcut">{item.shortcut}</kbd>}
|
||||
</button>
|
||||
))}
|
||||
{paletteItems.length === 0 && <div className="palette-empty">No results</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="header">
|
||||
<div className="header-drag" />
|
||||
<h1>Repos</h1>
|
||||
<div className="header-right">
|
||||
<span className="sync-status">Local</span>
|
||||
<button className="header-btn" onClick={() => setShowPalette(true)}>
|
||||
<kbd>Cmd+K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Folders</span>
|
||||
<button className="icon-btn" onClick={addFolder} title="Add folder">+</button>
|
||||
</div>
|
||||
{folders.length === 0 ? (
|
||||
<div className="sidebar-empty">
|
||||
No folders added.<br />
|
||||
<button className="link-btn" onClick={addFolder}>Add a folder</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="folder-list">
|
||||
{folders.map((path) => (
|
||||
<li key={path} className="folder-item">
|
||||
<span className="folder-path" title={path}>{path.split("/").pop()}</span>
|
||||
<button className="icon-btn remove" onClick={() => removeFolder(path)} title="Remove">×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button className="sidebar-refresh" onClick={scanRepos} disabled={isScanning || folders.length === 0}>
|
||||
{isScanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
{repos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{folders.length === 0 ? (
|
||||
<>
|
||||
<h2>No folders configured</h2>
|
||||
<p>Add folders where you keep your code to get started.</p>
|
||||
<button className="primary-btn" onClick={addFolder}>Add folder</button>
|
||||
</>
|
||||
) : isScanning ? (
|
||||
<>
|
||||
<div className="spinner" />
|
||||
<p>Scanning for repos...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>No repos found</h2>
|
||||
<p>No git repositories found in the configured folders.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="repo-list">
|
||||
{repos.map((repo) => (
|
||||
<li key={repo.path} className="repo-item">
|
||||
<div className="repo-info" onClick={() => openInEditor(repo)}>
|
||||
<span className="repo-name">{repo.name}</span>
|
||||
<span className="repo-path">{repo.path}</span>
|
||||
</div>
|
||||
<div className="repo-meta">
|
||||
<span className="repo-time">{timeAgo(repo.lastModified)}</span>
|
||||
<div className="repo-actions">
|
||||
<button className="action-btn" onClick={() => window.electronAPI.openInEditor(repo.path)} title="Open in VS Code">Code</button>
|
||||
<button className="action-btn" onClick={() => window.electronAPI.openInTerminal(repo.path)} title="Open in Terminal">Term</button>
|
||||
<button className="action-btn" onClick={() => window.electronAPI.showInFolder(repo.path)} title="Show in Finder">Finder</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
packages/desktop/src/renderer/env.d.ts
vendored
Normal file
21
packages/desktop/src/renderer/env.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
interface GitRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
pickFolder: () => Promise<string | null>;
|
||||
scanRepos: (folders: string[]) => Promise<GitRepo[]>;
|
||||
showInFolder: (path: string) => Promise<void>;
|
||||
openInEditor: (path: string) => Promise<void>;
|
||||
openInTerminal: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
86
packages/desktop/src/renderer/main.tsx
Normal file
86
packages/desktop/src/renderer/main.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { Suspense, Component, ReactNode } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { JazzReactProvider } from "jazz-tools/react"
|
||||
import { AppAccount } from "../features/folders/model/schema"
|
||||
import { App } from "./App"
|
||||
import "./styles.css"
|
||||
|
||||
const container = document.getElementById("root")
|
||||
|
||||
if (!container) {
|
||||
throw new Error("Root container not found")
|
||||
}
|
||||
|
||||
const apiKey = import.meta.env.VITE_JAZZ_API_KEY as string | undefined
|
||||
const customPeer = import.meta.env.VITE_JAZZ_PEER as string | undefined
|
||||
const peer = customPeer ?? (apiKey ? (`wss://cloud.jazz.tools/?key=${apiKey}` as const) : undefined)
|
||||
const syncConfig = peer ? { peer } : { when: "never" as const }
|
||||
|
||||
// Loading state
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<div className="spinner" />
|
||||
<p>Connecting...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error boundary for Jazz errors
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback?: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Jazz error:", error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="app loading-screen">
|
||||
<h2 style={{ color: "#ef4444", marginBottom: 8 }}>Connection Error</h2>
|
||||
<p style={{ color: "#888", maxWidth: 400, textAlign: "center", marginBottom: 16 }}>
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<button
|
||||
className="primary-btn"
|
||||
onClick={() => {
|
||||
this.setState({ error: null })
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<JazzReactProvider
|
||||
AccountSchema={AppAccount}
|
||||
storage="indexedDB"
|
||||
defaultProfileName="Linsa Desktop"
|
||||
authSecretStorageKey="linsa-desktop-jazz"
|
||||
sync={syncConfig}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</JazzReactProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
619
packages/desktop/src/renderer/styles.css
Normal file
619
packages/desktop/src/renderer/styles.css
Normal file
@@ -0,0 +1,619 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--bg-secondary: #161616;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--border: #2a2a2a;
|
||||
--text: #e5e5e5;
|
||||
--text-secondary: #888;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-rows: 48px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.header-drag {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.header-btn kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sync-status.sync-connected {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-refresh {
|
||||
margin: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-refresh:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sidebar-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
padding: 18px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Repo list */
|
||||
.repo-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.repo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.repo-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.repo-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.repo-path {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.repo-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.repo-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.repo-item:hover .repo-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 18px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.folder-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-input.has-error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.empty-inline {
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Command Palette */
|
||||
.palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 20vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.palette-commands {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.palette-command {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.palette-command:hover,
|
||||
.palette-command.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.palette-shortcut {
|
||||
padding: 3px 6px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
51
packages/desktop/src/shared/jazzSchema.ts
Normal file
51
packages/desktop/src/shared/jazzSchema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { co, setDefaultSchemaPermissions, z } from "jazz-tools";
|
||||
|
||||
setDefaultSchemaPermissions({
|
||||
onInlineCreate: "sameAsContainer",
|
||||
});
|
||||
|
||||
export const LocalTrack = co.map({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
url: z.string(),
|
||||
addedAt: z.number(),
|
||||
lastPlayedAt: z.optional(z.number()),
|
||||
});
|
||||
export type LocalTrack = co.loaded<typeof LocalTrack>;
|
||||
|
||||
export const PlayerRoot = co
|
||||
.map({
|
||||
recentTracks: co.list(LocalTrack),
|
||||
lastPlayedTrackId: z.optional(z.string()),
|
||||
})
|
||||
.withPermissions({ onInlineCreate: "newGroup" });
|
||||
export type PlayerRoot = co.loaded<typeof PlayerRoot>;
|
||||
|
||||
export const PlayerAccount = co
|
||||
.account({
|
||||
profile: co.profile({
|
||||
avatar: co.optional(co.image()),
|
||||
}),
|
||||
root: PlayerRoot,
|
||||
})
|
||||
.withMigration(async (account) => {
|
||||
if (!account.$jazz.has("root")) {
|
||||
account.$jazz.set("root", {
|
||||
recentTracks: [],
|
||||
lastPlayedTrackId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.$jazz.has("profile")) {
|
||||
account.$jazz.set("profile", {
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.resolved({
|
||||
profile: true,
|
||||
root: {
|
||||
recentTracks: { $each: true },
|
||||
},
|
||||
});
|
||||
export type PlayerAccount = co.loaded<typeof PlayerAccount>;
|
||||
49
packages/desktop/src/shared/lib/jazz/context.ts
Normal file
49
packages/desktop/src/shared/lib/jazz/context.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { atom, computed, withSuspenseInit, wrap } from "@reatom/core"
|
||||
import { JazzContextManager, type AccountSchema, BrowserContext } from "jazz-tools"
|
||||
|
||||
export async function createElectronJazzApp<S extends AccountSchema>(opts: {
|
||||
AccountSchema: S
|
||||
sync?: { peer: string }
|
||||
}) {
|
||||
const contextManager = new JazzContextManager<S>()
|
||||
|
||||
await contextManager.createContext({
|
||||
...opts,
|
||||
guestMode: false,
|
||||
storage: "indexedDB",
|
||||
defaultProfileName: "Linsa Desktop",
|
||||
authSecretStorageKey: "linsa-desktop-auth",
|
||||
})
|
||||
|
||||
return contextManager
|
||||
}
|
||||
|
||||
// Jazz context as a Reatom atom
|
||||
const jazzApp = atom(async () => {
|
||||
const { AppAccount } = await wrap(import("@/features/folders/model/schema"))
|
||||
|
||||
return wrap(
|
||||
createElectronJazzApp({
|
||||
AccountSchema: AppAccount,
|
||||
// No sync for now - local only
|
||||
// sync: { peer: "wss://cloud.jazz.tools/?key=..." },
|
||||
})
|
||||
)
|
||||
}, "jazzApp").extend(withSuspenseInit())
|
||||
|
||||
export type JazzContextAtom = typeof jazzContext
|
||||
export const jazzContext = computed(() => {
|
||||
const contextManager = jazzApp()
|
||||
|
||||
const requireCurrentContext = () => {
|
||||
const currentValue = contextManager.getCurrentValue()
|
||||
if (!currentValue) throw new Error("Jazz context not ready")
|
||||
if (!("me" in currentValue)) throw new Error("Guest mode not supported")
|
||||
return currentValue as BrowserContext<typeof import("@/features/folders/model/schema").AppAccount>
|
||||
}
|
||||
|
||||
return {
|
||||
current: requireCurrentContext,
|
||||
manager: contextManager,
|
||||
}
|
||||
}, "jazzContext")
|
||||
104
packages/desktop/src/shared/lib/reatom/jazz.ts
Normal file
104
packages/desktop/src/shared/lib/reatom/jazz.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
type Atom,
|
||||
atom,
|
||||
reatomMap,
|
||||
type Rec,
|
||||
withConnectHook,
|
||||
withSuspenseInit,
|
||||
wrap,
|
||||
} from "@reatom/core"
|
||||
|
||||
import {
|
||||
co,
|
||||
coValueClassFromCoValueClassOrSchema,
|
||||
type CoValueClassOrSchema,
|
||||
loadCoValue,
|
||||
type ResolveQuery,
|
||||
type ResolveQueryStrict,
|
||||
subscribeToCoValue,
|
||||
} from "jazz-tools"
|
||||
|
||||
export const reatomJazz = <
|
||||
Schema extends CoValueClassOrSchema,
|
||||
Return extends Rec,
|
||||
const Resolve extends ResolveQuery<Schema> = true,
|
||||
>(options: {
|
||||
schema: Schema
|
||||
resolve?: ResolveQueryStrict<Schema, Resolve>
|
||||
create: (api: {
|
||||
loaded: co.loaded<Schema, Resolve>
|
||||
name: string
|
||||
target: Atom<{ co: co.loaded<Schema, Resolve> }>
|
||||
}) => Return
|
||||
onUnauthorized?: () => void
|
||||
onUnavailable?: () => void
|
||||
name?: string
|
||||
}) => {
|
||||
const { create, resolve, onUnauthorized, onUnavailable, name = "coValue" } =
|
||||
options
|
||||
|
||||
type AtomState = Return & { id: string; co: co.loaded<Schema, Resolve> }
|
||||
|
||||
const cache = reatomMap<string, Atom<AtomState> & { id: string }>(
|
||||
undefined,
|
||||
`${name}._cache`
|
||||
)
|
||||
|
||||
return (id: string) => {
|
||||
const factoryName = `${name}.${id}`
|
||||
return cache.getOrCreate(factoryName, () => {
|
||||
const stateAtom = atom(async () => {
|
||||
return loadCoValue(
|
||||
coValueClassFromCoValueClassOrSchema(options.schema),
|
||||
id,
|
||||
// @ts-expect-error resolve types
|
||||
{ resolve }
|
||||
)
|
||||
.then(
|
||||
wrap((result) => {
|
||||
if (result.$isLoaded) {
|
||||
const loaded = result as co.loaded<Schema, Resolve>
|
||||
const factoryReturn = create({
|
||||
loaded,
|
||||
name: factoryName,
|
||||
target: stateAtom,
|
||||
})
|
||||
return Object.assign(factoryReturn, { id, co: loaded })
|
||||
} else {
|
||||
throw new Error(`Failed to load ${factoryName}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(
|
||||
wrap((error) => {
|
||||
throw new Error(`Failed to build ${factoryName}: ${error}`)
|
||||
})
|
||||
)
|
||||
}, `${name}.loaded`).extend(
|
||||
withSuspenseInit(),
|
||||
withConnectHook((target) => {
|
||||
return subscribeToCoValue(
|
||||
coValueClassFromCoValueClassOrSchema(options.schema),
|
||||
id,
|
||||
{
|
||||
// @ts-expect-error resolve types
|
||||
resolve,
|
||||
onUnauthorized: wrap(() => onUnauthorized?.()),
|
||||
onUnavailable: wrap(() => onUnavailable?.()),
|
||||
},
|
||||
wrap((loaded: co.loaded<Schema, Resolve>) => {
|
||||
const factoryReturn = create({
|
||||
loaded,
|
||||
name: factoryName,
|
||||
target,
|
||||
})
|
||||
target.set(Object.assign(factoryReturn, { id, co: loaded }))
|
||||
})
|
||||
)
|
||||
}),
|
||||
() => ({ id })
|
||||
)
|
||||
return stateAtom
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user