diff --git a/apps/shell/src/main/cloud-projects.ts b/apps/shell/src/main/cloud-projects.ts deleted file mode 100644 index 7ab5808..0000000 --- a/apps/shell/src/main/cloud-projects.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { gskApiKey, gskListPastProjects, hasGskAuth } from '@hermesoffice/ai-search' -import type { CloudProjectEntry, CloudProjectKind, CloudProjectsSnapshot } from '../shared/home-api' - -const SYNC_PAGE = 100 -/** bound on sync cost for huge accounts (10 requests) */ -const MAX_PROJECTS = 1000 - -const KINDS: readonly CloudProjectKind[] = ['docs', 'sheets', 'slides'] - -/** 'slides_agent_git' / 'docs_agent' / 'sheets_agent_new' → module kind */ -export function kindFromType(type: string): CloudProjectKind | 'other' { - return KINDS.find((k) => type.startsWith(k)) ?? 'other' -} - -export const GENSPARK_ORIGIN = 'https://www.genspark.ai' - -/** API ctime carries no timezone marker and is UTC; append Z before parsing */ -function ctimeToMs(ctime: string): number { - if (!ctime) return 0 - const iso = /[zZ]$|[+-]\d\d:?\d\d$/.test(ctime) ? ctime : `${ctime}Z` - const ms = Date.parse(iso) - return Number.isFinite(ms) ? ms : 0 -} - -/** - * Account tag the store is bound to: a salted hash of the API key (never the - * key itself). A key change — logout, account switch, even rotation — makes - * the stored list unreadable so one account can never see another's projects. - * (exported for tests) - */ -export function cloudStoreOwner(): string { - const key = gskApiKey() - if (!key) return '' - return createHash('sha256').update(`hermesoffice-cloud-store:${key}`).digest('hex').slice(0, 16) -} - -/** disk shape: the snapshot plus the owner tag (stripped before it reaches the renderer) */ -type StoredSnapshot = CloudProjectsSnapshot & { owner?: string } - -export function clearCloudProjectsStore(storePath: string): void { - try { - unlinkSync(storePath) - } catch { - // nothing cached; fine - } -} - -/** Locally stored full project list; filtering/paging happen in the renderer. */ -export function readCloudProjectsStore(storePath: string): CloudProjectsSnapshot | null { - if (!hasGskAuth()) return null - try { - const raw = JSON.parse(readFileSync(storePath, 'utf-8')) as StoredSnapshot - if (!Array.isArray(raw.projects)) return null - if (raw.owner !== cloudStoreOwner()) { - // another account's cache; drop it from disk rather than leave it around - clearCloudProjectsStore(storePath) - return null - } - return { - available: true, - projects: raw.projects.map((p) => ({ ...p, kind: p.kind ?? 'other' })), - syncedAt: Number(raw.syncedAt) || 0, - } - } catch { - return null - } -} - -let syncInFlight: Promise | null = null -let syncInFlightOwner = '' - -/** - * Full-list sync, deduped so concurrent callers on the SAME account share one - * run. A caller under a different key never gets the other account's promise; - * it starts its own run (the orphaned one aborts at its next owner check). - */ -export function syncCloudProjects(storePath: string): Promise { - if (!hasGskAuth()) { - return Promise.resolve({ available: false, projects: [], syncedAt: 0 }) - } - const owner = cloudStoreOwner() - if (!syncInFlight || syncInFlightOwner !== owner) { - const run = doSync(storePath, owner).finally(() => { - if (syncInFlight === run) syncInFlight = null - }) - syncInFlight = run - syncInFlightOwner = owner - } - return syncInFlight -} - -/** - * Pages are newest-first and ctime is immutable, so when the FIRST page is - * entirely known (ids + titles) and the API total matches the store size, - * nothing changed and the store is kept as-is (one request). Any difference - * triggers a full sweep, which also picks up renames and drops deletions. - * - * `owner` is the account the sync was started for. Each page is fetched with - * the LIVE key, so if the account switches (or logs out) mid-sync the pages - * would belong to someone else; the check after every fetch aborts the run - * before mixed data can be returned or written to disk. The rejection reaches - * the renderer as a failed sync (null), which keeps whatever it had. - */ -async function doSync(storePath: string, owner: string): Promise { - const stored = readCloudProjectsStore(storePath) - const known = new Map((stored?.projects ?? []).map((p) => [p.projectId, p])) - const collected: CloudProjectEntry[] = [] - const seen = new Set() - let offset = 0 - for (;;) { - const page = await gskListPastProjects({ - artifactTypes: [...KINDS], - limit: SYNC_PAGE, - offset, - }) - if (cloudStoreOwner() !== owner) { - throw new Error('cloud projects sync aborted: account changed mid-sync') - } - const entries: CloudProjectEntry[] = page.projects - .map((p) => ({ - projectId: p.projectId, - title: p.title, - kind: kindFromType(p.type), - ctimeMs: ctimeToMs(p.ctime), - projectUrl: p.projectUrl, - })) - .filter((e) => !seen.has(e.projectId)) - for (const e of entries) seen.add(e.projectId) - collected.push(...entries) - if (offset === 0 && known.size === page.total) { - const unchanged = entries.every((e) => { - const k = known.get(e.projectId) - return k && k.title === e.title && k.kind === e.kind - }) - if (unchanged && stored) return stored - } - offset += page.projects.length - if (!page.hasMore || page.projects.length === 0 || collected.length >= MAX_PROJECTS) break - } - collected.sort((a, b) => b.ctimeMs - a.ctimeMs) - const snapshot: CloudProjectsSnapshot = { - available: true, - projects: collected, - syncedAt: Date.now(), - } - try { - const stored: StoredSnapshot = { ...snapshot, owner } - writeFileSync(storePath, JSON.stringify(stored)) - } catch { - // store is best-effort; the fresh list still goes back to the renderer - } - return snapshot -} - -/** Only relative genspark paths may be opened externally (renderer input is untrusted). */ -export function cloudProjectExternalUrl(projectUrl: unknown): string | null { - if (typeof projectUrl !== 'string') return null - if (!projectUrl.startsWith('/') || projectUrl.startsWith('//')) return null - return `${GENSPARK_ORIGIN}${projectUrl}` -} diff --git a/apps/shell/src/main/index.ts b/apps/shell/src/main/index.ts index 675c266..4e67450 100644 --- a/apps/shell/src/main/index.ts +++ b/apps/shell/src/main/index.ts @@ -58,12 +58,6 @@ import { windowMenuTemplate, } from '@hermesoffice/electron-utils' import { readAppSettings, writeAppSetting } from './app-settings' -import { - clearCloudProjectsStore, - cloudProjectExternalUrl, - readCloudProjectsStore, - syncCloudProjects, -} from './cloud-projects' import { ProjectStore } from '@hermesoffice/project-store' import { buildTarget, @@ -2117,7 +2111,6 @@ function registerHomeIpc(): void { ipcMain.handle(HOME_CHANNELS.accountLogout, async () => { // Fork: não há conta remota para encerrar sessão; só limpa o cache local - clearCloudProjectsStore(cloudProjectsStorePath()) }) ipcMain.handle(HOME_CHANNELS.getAppVersion, (): string => app.getVersion()) @@ -2385,19 +2378,6 @@ function registerHomeIpc(): void { // no browser handler available; nothing actionable for the user here }) }) - - const cloudProjectsStorePath = () => join(app.getPath('userData'), 'cloud-projects.json') - - ipcMain.handle(HOME_CHANNELS.cloudProjectsCached, () => - readCloudProjectsStore(cloudProjectsStorePath()), - ) - - ipcMain.handle(HOME_CHANNELS.cloudProjects, () => syncCloudProjects(cloudProjectsStorePath())) - - ipcMain.handle(HOME_CHANNELS.openCloudProject, (_event, projectUrl: unknown) => { - const url = cloudProjectExternalUrl(projectUrl) - if (url) void shell.openExternal(url) - }) } function stringPaths(value: unknown): string[] { diff --git a/apps/shell/src/preload/index.ts b/apps/shell/src/preload/index.ts index 5f46796..5b207a8 100644 --- a/apps/shell/src/preload/index.ts +++ b/apps/shell/src/preload/index.ts @@ -3,7 +3,6 @@ import type { IpcRendererEvent } from 'electron' import type { AccountLoginEvent, AccountStatus, - CloudProjectsSnapshot, HomeApi, RecentEntry, RecentPage, @@ -196,37 +195,9 @@ const homeApi: HomeApi = { async openCreditUsage() { await ipcRenderer.invoke(HOME_CHANNELS.openCreditUsage) }, - async cloudProjectsCached() { - const result: unknown = await ipcRenderer.invoke(HOME_CHANNELS.cloudProjectsCached) - return asCloudProjectsSnapshot(result) - }, - async cloudProjectsSync() { - // failures (network / CLI) resolve to null so the renderer keeps whatever it has - try { - const result: unknown = await ipcRenderer.invoke(HOME_CHANNELS.cloudProjects) - return asCloudProjectsSnapshot(result) - } catch { - return null - } - }, - async openCloudProject(projectUrl) { - if (typeof projectUrl !== 'string' || !projectUrl) throw new Error('Invalid project URL.') - await ipcRenderer.invoke(HOME_CHANNELS.openCloudProject, projectUrl) - }, hermesStatus: () => ipcRenderer.invoke(HOME_CHANNELS.hermesStatus), } -function asCloudProjectsSnapshot(result: unknown): CloudProjectsSnapshot | null { - if ( - result && - typeof result === 'object' && - Array.isArray((result as CloudProjectsSnapshot).projects) - ) { - return result as CloudProjectsSnapshot - } - return null -} - contextBridge.exposeInMainWorld('aiOffice', homeApi) const projectApi: ProjectHomeApi = { diff --git a/apps/shell/src/renderer/src/Home.tsx b/apps/shell/src/renderer/src/Home.tsx index 28423bf..926d49b 100644 --- a/apps/shell/src/renderer/src/Home.tsx +++ b/apps/shell/src/renderer/src/Home.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { ReactElement } from 'react' import logoLockup from './assets/hermesoffice-logo.svg' import iconDocx from './assets/file-docx.svg' import iconXlsx from './assets/file-xlsx.svg' @@ -8,8 +7,6 @@ import iconPdf from './assets/file-pdf.svg' import iconMd from './assets/file-md.svg' import type { AccountStatus, - CloudProjectKind, - CloudProjectsSnapshot, HomeApi, ProjectHomeApi, ProjectSummaryEntry, @@ -1130,345 +1127,6 @@ function AccountEntry({ ) } -const CLOUD_FILTERS = [ - { key: 'all', label: 'filterAll' }, - { key: 'docs', label: 'filterDocs' }, - { key: 'sheets', label: 'filterSheets' }, - { key: 'slides', label: 'filterSlides' }, -] as const satisfies readonly { key: 'all' | CloudProjectKind; label: StringKey }[] - -/** module kind → file icon extension */ -const CLOUD_KIND_EXT: Record = { docs: 'docx', sheets: 'xlsx', slides: 'pptx' } - -/** rows revealed per "load more" step; purely client-side over the local snapshot */ -const CLOUD_REVEAL_STEP = 100 - -function CloudProjectsView() { - const i18n = useI18n() - const { t } = i18n - const [snapshot, setSnapshot] = useState(null) - const [loading, setLoading] = useState(true) - const [syncing, setSyncing] = useState(false) - const [loginWaiting, setLoginWaiting] = useState(false) - const [kind, setKind] = useState<'all' | CloudProjectKind>('all') - const [query, setQuery] = useState('') - const [sort, setSort] = useState<'recent' | 'oldest'>('recent') - const [sortMenuOpen, setSortMenuOpen] = useState(false) - const [revealed, setRevealed] = useState(CLOUD_REVEAL_STEP) - const sortRef = useRef(null) - - // the local store paints instantly; a background sync replaces it when done. - // a failed sync keeps whatever is shown; with nothing shown the - // !snapshot && !loading branch below renders the retry state - const startSync = () => { - setSyncing(true) - void window.aiOffice.cloudProjectsSync?.().then((synced) => { - setSyncing(false) - setLoading(false) - if (synced) setSnapshot(synced) - }) - } - const startSyncRef = useRef(startSync) - startSyncRef.current = startSync - - useEffect(() => { - let cancelled = false - void window.aiOffice.cloudProjectsCached?.().then((stored) => { - if (cancelled || !stored) return - setSnapshot((prev) => prev ?? stored) - setLoading(false) - }) - startSyncRef.current() - return () => { - cancelled = true - } - }, []) - - // the sign-in button reuses the account login flow; sync once it lands - useEffect(() => { - const off = window.aiOffice.onAccountLogin?.((ev) => { - if (ev.phase === 'success') { - setLoginWaiting(false) - startSyncRef.current() - } else if (ev.phase === 'error') { - setLoginWaiting(false) - } - }) - return off - }, []) - - useEffect(() => { - if (!sortMenuOpen) return - const handler = (e: PointerEvent) => { - if (!sortRef.current?.contains(e.target as Node)) setSortMenuOpen(false) - } - window.addEventListener('pointerdown', handler) - return () => window.removeEventListener('pointerdown', handler) - }, [sortMenuOpen]) - - const startLogin = () => { - setLoginWaiting(true) - void window.aiOffice.accountLogin?.().then((ok) => { - if (!ok) setLoginWaiting(false) - }) - } - - const changeKind = (k: 'all' | CloudProjectKind) => { - if (k === kind) return - setKind(k) - setRevealed(CLOUD_REVEAL_STEP) - } - - const openProject = (projectUrl: string) => { - void window.aiOffice.openCloudProject?.(projectUrl) - } - - // filter / search / sort are all local over the snapshot — no requests - const q = query.trim().toLowerCase() - let list = snapshot?.projects.filter((proj) => kind === 'all' || proj.kind === kind) ?? [] - if (q) list = list.filter((proj) => proj.title.toLowerCase().includes(q)) - if (sort === 'oldest') list = [...list].reverse() - const visible = list.slice(0, revealed) - - /** time-bucket header: this week → earlier this month → month → month + year */ - const groupLabel = (ctimeMs: number): string => { - if (!ctimeMs) return '' - const now = Date.now() - if (now - ctimeMs < 7 * 86_400_000 && ctimeMs < now + 86_400_000) { - return t('cloudGroupThisWeek') - } - const d = new Date(ctimeMs) - const n = new Date() - if (d.getFullYear() === n.getFullYear()) { - if (d.getMonth() === n.getMonth()) return t('cloudGroupThisMonth') - return new Intl.DateTimeFormat(i18n.dateLocale, { month: 'long' }).format(d) - } - return new Intl.DateTimeFormat(i18n.dateLocale, { year: 'numeric', month: 'long' }).format(d) - } - - const renderRows = () => { - const items: ReactElement[] = [] - let prevLabel = '' - for (const proj of visible) { - const label = groupLabel(proj.ctimeMs) - if (label && label !== prevLabel) { - prevLabel = label - items.push( - , - ) - } - items.push( -
  • - -
  • , - ) - } - return items - } - - const renderBody = () => { - if (snapshot && !snapshot.available) { - return ( -

    - {t('cloudLoginHint')} - -

    - ) - } - if (!snapshot) { - if (loading || syncing) { - return ( - - ) - } - return ( -

    - {t('cloudError')} - -

    - ) - } - if (list.length === 0) { - return ( -

    - - {t(q ? 'cloudNoResults' : kind === 'all' ? 'cloudEmpty' : 'emptyFiltered')} - -

    - ) - } - return ( -
    -
      {renderRows()}
    - {list.length > revealed && ( -
    - -
    - )} -
    - ) - } - - const sortValueKey = sort === 'recent' ? 'cloudSortRecent' : 'cloudSortOldest' - return ( -
    -
    -
    -
    -

    - {t('navCloud')} - - - {t('cloudOpenInBrowser')} - -

    - {snapshot?.available && ( -
    - -
    - - {sortMenuOpen && ( -
    - {(['recent', 'oldest'] as const).map((key) => ( - - ))} -
    - )} -
    -
    - )} -
    -

    {t('cloudSubtitle')}

    - {snapshot?.available && ( -
    -
    - - { - setQuery(e.target.value) - setRevealed(CLOUD_REVEAL_STEP) - }} - /> -
    -
    - {CLOUD_FILTERS.map((f) => ( - - ))} -
    -
    - )} -
    - {renderBody()} -
    -
    - ) -} - -// ── Main component ────────────────────────────────────── - export function Home() { const i18n = useI18n() const { t, lang } = i18n @@ -1480,8 +1138,6 @@ export function Home() { const [navCounts, setNavCounts] = useState({ recent: 0, starred: 0 }) const [loadingMore, setLoadingMore] = useState(false) const [view, setView] = useState<'recent' | 'starred'>('recent') - // Genspark web projects take over the content area (like a selected project) - const [cloudMode, setCloudMode] = useState(false) const [filter, setFilter] = useState('all') const [rowMenu, setRowMenu] = useState(null) const [selected, setSelected] = useState>(new Set()) @@ -1489,15 +1145,10 @@ export function Home() { const [confirmDelete, setConfirmDelete] = useState(null) // name in the greeting; omitted when logged out const [accountName, setAccountName] = useState('') - // Genspark Projects is web-account data, so its nav entry only shows when logged in - const [loggedIn, setLoggedIn] = useState(false) // single source of account state: AccountEntry reports every change (initial // load, login, logout), keeping the greeting name and the nav entry in sync const handleAccountStatus = useCallback((s: AccountStatus | null) => { - const on = s?.loggedIn ?? false - setLoggedIn(on) - if (!on) setCloudMode(false) - const name = on ? (s?.email ?? '').split('@')[0] : '' + const name = s?.loggedIn ? (s.email ?? '').split('@')[0] : '' setAccountName(name ? name[0].toUpperCase() + name.slice(1) : '') }, []) const [greetAskKey] = useState( @@ -2360,11 +2011,10 @@ export function Home() { {/* project sidebar */} @@ -2461,13 +2073,7 @@ export function Home() { - {selectedProjectId ? ( - renderProjectContent() - ) : cloudMode ? ( - - ) : ( - renderGlobalContent() - )} + {selectedProjectId ? renderProjectContent() : renderGlobalContent()} {confirmDelete && (
    setConfirmDelete(null)}> diff --git a/apps/shell/src/renderer/src/home.css b/apps/shell/src/renderer/src/home.css index b25b4d6..1338ef6 100644 --- a/apps/shell/src/renderer/src/home.css +++ b/apps/shell/src/renderer/src/home.css @@ -1397,527 +1397,6 @@ body.vib .home { padding-top: 1px; } -/* ── Cloud (Genspark web) projects ── */ - -/* fixed header: the content column never scrolls; only .cloud-scroll does */ -.content:has(.cloud-projects) { - display: flex; - flex-direction: column; - overflow: hidden; - padding: 26px 36px 24px; -} - -.cloud-projects { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} - -.cloud-projects .proj-empty { - flex: 1; -} - -.cloud-hero { - flex-shrink: 0; -} - -.cloud-hero-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.cloud-title { - display: flex; - align-items: center; - gap: 12px; - margin: 0; - font-size: 26px; - font-weight: 700; - letter-spacing: -0.01em; - color: var(--text); -} - -.cloud-chip { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: var(--text-secondary); - background: #eef0f2; - border-radius: 999px; - padding: 5px 11px; - white-space: nowrap; -} - -.cloud-subtitle { - margin: 8px 0 0; - font-size: 14px; - color: var(--text-muted); -} - -.cloud-sort { - position: relative; - flex-shrink: 0; -} - -.cloud-sort-btn { - display: flex; - align-items: center; - gap: 7px; - border: 1px solid var(--border); - background: var(--surface); - border-radius: 10px; - padding: 8px 13px; - font-size: 13px; - color: var(--text); - cursor: pointer; - white-space: nowrap; -} - -.cloud-sort-btn:hover { - background: var(--surface-subtle); -} - -.cloud-sort-menu { - position: absolute; - right: 0; - top: calc(100% + 6px); - min-width: 150px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 8px 24px rgb(0 0 0 / 10%); - padding: 4px; - z-index: 5; -} - -.cloud-sort-menu button { - display: block; - width: 100%; - text-align: left; - border: none; - background: none; - padding: 8px 11px; - font-size: 13px; - border-radius: 7px; - cursor: pointer; - color: var(--text); -} - -.cloud-sort-menu button:hover { - background: var(--surface-subtle); -} - -.cloud-sort-menu button.active { - font-weight: 600; -} - -.cloud-controls { - display: flex; - align-items: center; - gap: 14px; - margin-top: 18px; -} - -.cloud-search { - flex: 1; - max-width: 520px; - display: flex; - align-items: center; - gap: 9px; - height: 40px; - padding: 0 13px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--surface); - color: var(--text-muted); -} - -.cloud-search:focus-within { - border-color: var(--accent); -} - -.cloud-search input { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-size: 14px; - color: var(--text); -} - -.cloud-seg { - display: flex; - background: #f0f1f3; - border-radius: 10px; - padding: 3px; - flex-shrink: 0; -} - -.cloud-seg button { - border: none; - background: none; - padding: 7px 15px; - font-size: 13px; - color: var(--text-secondary); - border-radius: 8px; - cursor: pointer; - white-space: nowrap; -} - -.cloud-seg button.active { - background: var(--surface); - color: var(--text); - font-weight: 600; - box-shadow: 0 1px 2px rgb(0 0 0 / 8%); -} - -.cloud-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - margin-top: 4px; -} - -.cloud-list { - list-style: none; - margin: 0; - padding: 0; -} - -.cloud-group-label { - font-size: 12px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-muted); - padding: 24px 12px 10px; -} - -.cloud-list li:first-child.cloud-group-label { - padding-top: 14px; -} - -.cloud-list li + li:not(.cloud-group-label) .cloud-row { - border-top: 1px solid #f1f2f4; -} - -.cloud-row { - display: flex; - align-items: center; - gap: 13px; - width: 100%; - padding: 15px 12px; - border: none; - background: none; - cursor: pointer; - text-align: left; - font: inherit; - color: var(--text); - border-radius: 10px; -} - -.cloud-row:hover { - background: var(--surface-subtle); - border-color: transparent; -} - -.cloud-row:hover + li .cloud-row { - border-top-color: transparent; -} - -.cloud-row:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -/* title + trailing external icon; the icon hugs the text and survives truncation */ -.cloud-row-main { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 11px; -} - -.cloud-row-title { - flex: 0 1 auto; - min-width: 0; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.cloud-row-time { - flex-shrink: 0; - font-size: 12px; - color: var(--text-muted); - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -/* always visible so rows read as external links; darkens on hover */ -.cloud-row-external { - flex-shrink: 0; - color: #c9ced4; -} - -.cloud-row:hover .cloud-row-external, -.cloud-row:focus-visible .cloud-row-external { - color: var(--text-secondary); -} - -/* right-aligned to match the sidebar count column (22px, centered digits) */ -.nav-external { - flex-shrink: 0; - margin-left: auto; - margin-right: 4px; - color: var(--text-muted); -} - -.cloud-actions { - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; -} - -.cloud-refresh-btn { - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border: 1px solid var(--border); - background: var(--surface); - border-radius: 10px; - color: var(--text); - cursor: pointer; -} - -.cloud-refresh-btn:hover:not(:disabled) { - background: var(--surface-subtle); -} - -.cloud-refresh-btn.syncing svg { - animation: cloud-refresh-spin 0.9s linear infinite; - color: var(--text-muted); -} - -@keyframes cloud-refresh-spin { - to { - transform: rotate(360deg); - } -} - -/* ---- cloud sync panel (sidebar) — follows the .nav-item design system ---- */ - -.cloud-panel { - padding: 2px 0 0; - margin-top: 2px; -} - -.cloud-panel-header { - display: flex; - align-items: center; - gap: 9px; - width: 100%; - padding: 8px 10px; - border: none; - border-radius: 8px; - background: none; - font: inherit; - font-size: 14px; - color: var(--text); - cursor: pointer; - text-align: left; -} - -.cloud-panel-header:hover { - background: #f5f5f5; -} - -.cloud-panel-header:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.cloud-icon { - flex-shrink: 0; - color: #232425; -} - -.cloud-panel-title { - flex-shrink: 0; - font-size: 14px; -} - -.cloud-status { - margin-left: auto; - min-width: 22px; - text-align: right; - font-size: 12px; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.cloud-status.on { - color: #15803d; -} - -.cloud-chevron { - flex-shrink: 0; - color: var(--text-muted); - transition: transform 150ms ease; -} - -.cloud-panel-header.expanded .cloud-chevron { - transform: rotate(180deg); -} - -.cloud-panel-body { - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px 10px 12px; -} - -.cloud-field { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--text-secondary); -} - -.cloud-field select, -.cloud-field input[type='text'], -.cloud-field input:not([type]) { - width: 100%; - padding: 6px 9px; - border: 1px solid var(--border); - border-radius: 6px; - font-family: inherit; - font-size: 13px; - background: var(--surface); - color: var(--text); - outline: none; - transition: - border-color 120ms ease, - box-shadow 120ms ease; -} - -.cloud-field input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgb(35 103 236 / 14%); -} - -.cloud-field.cloud-check { - flex-direction: row; - align-items: center; - gap: 7px; - cursor: pointer; -} - -.cloud-field.cloud-check input[type='checkbox'] { - width: 15px; - height: 15px; - margin: 0; - appearance: none; - border: 1px solid #d9d9d9; - border-radius: 4px; - background: var(--surface); - cursor: pointer; - transition: - border-color 120ms ease, - background 120ms ease; -} - -.cloud-field.cloud-check input[type='checkbox']:checked { - border-color: var(--accent); - background: var(--accent) - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 6.2l2.4 2.4 4.6-5' stroke='%23fff' stroke-width='1.8' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") - center / 10px 10px no-repeat; -} - -.cloud-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.cloud-connect-btn { - justify-content: center; - background: var(--accent); - border: none; - color: #fff; -} - -.cloud-connect-btn:hover:not(:disabled) { - background: var(--accent-hover); -} - -.cloud-connect-btn:disabled { - opacity: 0.6; - cursor: default; -} - -.cloud-test-btn { - flex: 1; - justify-content: center; -} - -.cloud-test-btn:disabled { - opacity: 0.5; - cursor: default; -} - -.cloud-disconnect-btn { - padding: 6px 10px; - border: none; - border-radius: 6px; - background: none; - color: var(--text-muted); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - background 120ms ease, - color 120ms ease; -} - -.cloud-disconnect-btn:hover { - background: rgb(239 68 68 / 8%); - color: var(--color-error); -} - -.cloud-latest { - font-size: 12px; - color: var(--accent); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cloud-latest:hover { - text-decoration: underline; -} - -.cloud-error { - margin: 0; - font-size: 12px; - color: var(--color-error); -} - /* ── Cloud sync entry (sidebar; appended — do not move) ───────────── Single connect button that becomes a live sync status row once the user's Google Drive is connected (embedded OAuth, system browser). */ diff --git a/apps/shell/src/renderer/src/strings.ts b/apps/shell/src/renderer/src/strings.ts index bc10fe0..82e71a9 100644 --- a/apps/shell/src/renderer/src/strings.ts +++ b/apps/shell/src/renderer/src/strings.ts @@ -4,20 +4,8 @@ export const strings = { // Sidebar navigation navRecent: '最近', navStarred: '收藏', - navCloud: 'Genspark Projects', - cloudSubtitle: '在网页端用 Genspark AI 创建的项目。编辑在浏览器中继续——点击任意项目即可打开。', - cloudSearchPlaceholder: '搜索 {n} 个项目…', - cloudNoResults: '没有匹配的项目。', - cloudGroupThisWeek: '本周', cloudGroupThisMonth: '本月', cloudSortLabel: '排序:{v}', - cloudSortRecent: '最近', - cloudSortOldest: '最早', - cloudRefresh: '刷新', - cloudLoginHint: '登录 Genspark 账号,查看你在网页端创建的项目。', - cloudEmpty: '还没有网页端项目。', - cloudError: '加载失败,请稍后重试。', - cloudRetry: '重试', cloudLoadMore: '加载更多', cloudOpenInBrowser: '在浏览器中打开', navTrash: '回收站', @@ -186,21 +174,8 @@ export const strings = { en: { navRecent: 'Recent', navStarred: 'Starred', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Projects created on the web with Genspark AI. Editing continues in your browser — click any project to open it.', - cloudSearchPlaceholder: 'Search {n} projects…', - cloudNoResults: 'No matching projects.', - cloudGroupThisWeek: 'This week', cloudGroupThisMonth: 'Earlier this month', cloudSortLabel: 'Sort: {v}', - cloudSortRecent: 'Recent', - cloudSortOldest: 'Oldest', - cloudRefresh: 'Refresh', - cloudLoginHint: 'Sign in to your Genspark account to see projects you created on the web.', - cloudEmpty: 'No web projects yet.', - cloudError: 'Failed to load. Try again later.', - cloudRetry: 'Retry', cloudLoadMore: 'Load more', cloudOpenInBrowser: 'Open in browser', navTrash: 'Trash', @@ -364,22 +339,8 @@ export const strings = { // Sidebar navigation navRecent: '最近使用', navStarred: 'お気に入り', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Web で Genspark AI を使って作成したプロジェクト。編集はブラウザで続行します。クリックで開きます。', - cloudSearchPlaceholder: '{n} 件のプロジェクトを検索…', - cloudNoResults: '一致するプロジェクトはありません。', - cloudGroupThisWeek: '今週', cloudGroupThisMonth: '今月', cloudSortLabel: '並び替え: {v}', - cloudSortRecent: '新しい順', - cloudSortOldest: '古い順', - cloudRefresh: '更新', - cloudLoginHint: - 'Genspark アカウントにサインインすると、Web で作成したプロジェクトを表示できます。', - cloudEmpty: 'Web のプロジェクトはまだありません。', - cloudError: '読み込みに失敗しました。後でもう一度お試しください。', - cloudRetry: '再試行', cloudLoadMore: 'もっと見る', cloudOpenInBrowser: 'ブラウザで開く', navTrash: 'ゴミ箱', @@ -557,21 +518,8 @@ export const strings = { // Sidebar navigation navRecent: '최근 사용', navStarred: '즐겨찾기', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Genspark AI로 웹에서 만든 프로젝트입니다. 편집은 브라우저에서 계속됩니다. 프로젝트를 클릭하면 열립니다.', - cloudSearchPlaceholder: '프로젝트 {n}개 검색…', - cloudNoResults: '일치하는 프로젝트가 없습니다.', - cloudGroupThisWeek: '이번 주', cloudGroupThisMonth: '이번 달', cloudSortLabel: '정렬: {v}', - cloudSortRecent: '최신순', - cloudSortOldest: '오래된순', - cloudRefresh: '새로고침', - cloudLoginHint: 'Genspark 계정에 로그인하면 웹에서 만든 프로젝트를 볼 수 있습니다.', - cloudEmpty: '아직 웹 프로젝트가 없습니다.', - cloudError: '불러오지 못했습니다. 나중에 다시 시도해 주세요.', - cloudRetry: '다시 시도', cloudLoadMore: '더 보기', cloudOpenInBrowser: '브라우저에서 열기', navTrash: '휴지통', @@ -745,22 +693,8 @@ export const strings = { // Sidebar navigation navRecent: 'Récents', navStarred: 'Favoris', - navCloud: 'Genspark Projects', - cloudSubtitle: - "Projets créés sur le web avec Genspark AI. L'édition continue dans votre navigateur — cliquez sur un projet pour l'ouvrir.", - cloudSearchPlaceholder: 'Rechercher parmi {n} projets…', - cloudNoResults: 'Aucun projet correspondant.', - cloudGroupThisWeek: 'Cette semaine', cloudGroupThisMonth: 'Plus tôt ce mois-ci', cloudSortLabel: 'Tri : {v}', - cloudSortRecent: 'Récents', - cloudSortOldest: 'Plus anciens', - cloudRefresh: 'Actualiser', - cloudLoginHint: - 'Connectez-vous à votre compte Genspark pour voir les projets créés sur le web.', - cloudEmpty: 'Aucun projet web pour le moment.', - cloudError: 'Échec du chargement. Réessayez plus tard.', - cloudRetry: 'Réessayer', cloudLoadMore: 'Charger plus', cloudOpenInBrowser: 'Ouvrir dans le navigateur', navTrash: 'Corbeille', @@ -938,22 +872,8 @@ export const strings = { // Sidebar navigation navRecent: 'Zuletzt verwendet', navStarred: 'Favoriten', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Mit Genspark AI im Web erstellte Projekte. Die Bearbeitung läuft im Browser weiter – klicken Sie auf ein Projekt, um es zu öffnen.', - cloudSearchPlaceholder: '{n} Projekte durchsuchen…', - cloudNoResults: 'Keine passenden Projekte.', - cloudGroupThisWeek: 'Diese Woche', cloudGroupThisMonth: 'Früher in diesem Monat', cloudSortLabel: 'Sortierung: {v}', - cloudSortRecent: 'Neueste', - cloudSortOldest: 'Älteste', - cloudRefresh: 'Aktualisieren', - cloudLoginHint: - 'Melden Sie sich bei Ihrem Genspark-Konto an, um Ihre im Web erstellten Projekte zu sehen.', - cloudEmpty: 'Noch keine Web-Projekte.', - cloudError: 'Laden fehlgeschlagen. Bitte später erneut versuchen.', - cloudRetry: 'Erneut versuchen', cloudLoadMore: 'Mehr laden', cloudOpenInBrowser: 'Im Browser öffnen', navTrash: 'Papierkorb', @@ -1133,22 +1053,8 @@ export const strings = { // Sidebar navigation navRecent: 'Recientes', navStarred: 'Destacados', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Proyectos creados en la web con Genspark AI. La edición continúa en tu navegador: haz clic en un proyecto para abrirlo.', - cloudSearchPlaceholder: 'Buscar entre {n} proyectos…', - cloudNoResults: 'No hay proyectos coincidentes.', - cloudGroupThisWeek: 'Esta semana', cloudGroupThisMonth: 'Este mes', cloudSortLabel: 'Orden: {v}', - cloudSortRecent: 'Recientes', - cloudSortOldest: 'Más antiguos', - cloudRefresh: 'Actualizar', - cloudLoginHint: - 'Inicia sesión en tu cuenta de Genspark para ver los proyectos creados en la web.', - cloudEmpty: 'Aún no hay proyectos en la web.', - cloudError: 'Error al cargar. Inténtalo más tarde.', - cloudRetry: 'Reintentar', cloudLoadMore: 'Cargar más', cloudOpenInBrowser: 'Abrir en el navegador', navTrash: 'Papelera', @@ -1327,21 +1233,8 @@ export const strings = { // Sidebar navigation navRecent: 'ล่าสุด', navStarred: 'รายการโปรด', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'โปรเจกต์ที่สร้างบนเว็บด้วย Genspark AI แก้ไขต่อได้ในเบราว์เซอร์ — คลิกโปรเจกต์เพื่อเปิด', - cloudSearchPlaceholder: 'ค้นหา {n} โปรเจกต์…', - cloudNoResults: 'ไม่มีโปรเจกต์ที่ตรงกัน', - cloudGroupThisWeek: 'สัปดาห์นี้', cloudGroupThisMonth: 'เดือนนี้', cloudSortLabel: 'เรียง: {v}', - cloudSortRecent: 'ล่าสุด', - cloudSortOldest: 'เก่าสุด', - cloudRefresh: 'รีเฟรช', - cloudLoginHint: 'ลงชื่อเข้าใช้บัญชี Genspark เพื่อดูโปรเจกต์ที่คุณสร้างบนเว็บ', - cloudEmpty: 'ยังไม่มีโปรเจกต์บนเว็บ', - cloudError: 'โหลดไม่สำเร็จ โปรดลองอีกครั้งภายหลัง', - cloudRetry: 'ลองอีกครั้ง', cloudLoadMore: 'โหลดเพิ่มเติม', cloudOpenInBrowser: 'เปิดในเบราว์เซอร์', navTrash: 'ถังขยะ', @@ -1514,21 +1407,8 @@ export const strings = { // Sidebar navigation navRecent: 'Terbaru', navStarred: 'Berbintang', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Proyek yang dibuat di web dengan Genspark AI. Pengeditan berlanjut di browser — klik proyek untuk membukanya.', - cloudSearchPlaceholder: 'Cari {n} proyek…', - cloudNoResults: 'Tidak ada proyek yang cocok.', - cloudGroupThisWeek: 'Minggu ini', cloudGroupThisMonth: 'Bulan ini', cloudSortLabel: 'Urutkan: {v}', - cloudSortRecent: 'Terbaru', - cloudSortOldest: 'Terlama', - cloudRefresh: 'Segarkan', - cloudLoginHint: 'Masuk ke akun Genspark untuk melihat proyek yang Anda buat di web.', - cloudEmpty: 'Belum ada proyek web.', - cloudError: 'Gagal memuat. Coba lagi nanti.', - cloudRetry: 'Coba lagi', cloudLoadMore: 'Muat lebih banyak', cloudOpenInBrowser: 'Buka di browser', navTrash: 'Sampah', @@ -1704,21 +1584,8 @@ export const strings = { // Sidebar navigation navRecent: 'Недавние', navStarred: 'Избранное', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Проекты, созданные в вебе с Genspark AI. Редактирование продолжается в браузере — нажмите на проект, чтобы открыть его.', - cloudSearchPlaceholder: 'Поиск среди {n} проектов…', - cloudNoResults: 'Нет подходящих проектов.', - cloudGroupThisWeek: 'На этой неделе', cloudGroupThisMonth: 'Ранее в этом месяце', cloudSortLabel: 'Сортировка: {v}', - cloudSortRecent: 'Сначала новые', - cloudSortOldest: 'Сначала старые', - cloudRefresh: 'Обновить', - cloudLoginHint: 'Войдите в аккаунт Genspark, чтобы увидеть проекты, созданные в вебе.', - cloudEmpty: 'Пока нет веб-проектов.', - cloudError: 'Не удалось загрузить. Повторите попытку позже.', - cloudRetry: 'Повторить', cloudLoadMore: 'Загрузить ещё', cloudOpenInBrowser: 'Открыть в браузере', navTrash: 'Корзина', @@ -1894,21 +1761,8 @@ export const strings = { // Sidebar navigation navRecent: 'الأخيرة', navStarred: 'المفضلة', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'مشاريع أُنشئت على الويب باستخدام Genspark AI. يستمر التحرير في المتصفح — انقر على أي مشروع لفتحه.', - cloudSearchPlaceholder: 'ابحث في {n} مشروعًا…', - cloudNoResults: 'لا توجد مشاريع مطابقة.', - cloudGroupThisWeek: 'هذا الأسبوع', cloudGroupThisMonth: 'في وقت سابق من هذا الشهر', cloudSortLabel: 'الترتيب: {v}', - cloudSortRecent: 'الأحدث', - cloudSortOldest: 'الأقدم', - cloudRefresh: 'تحديث', - cloudLoginHint: 'سجّل الدخول إلى حساب Genspark لعرض المشاريع التي أنشأتها على الويب.', - cloudEmpty: 'لا توجد مشاريع على الويب بعد.', - cloudError: 'فشل التحميل. حاول مرة أخرى لاحقًا.', - cloudRetry: 'إعادة المحاولة', cloudLoadMore: 'تحميل المزيد', cloudOpenInBrowser: 'فتح في المتصفح', navTrash: 'سلة المهملات', @@ -2081,21 +1935,8 @@ export const strings = { pt: { navRecent: 'Recentes', navStarred: 'Favoritos', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Projetos criados na web com o Genspark AI. A edição continua no navegador — clique em um projeto para abri-lo.', - cloudSearchPlaceholder: 'Pesquisar {n} projetos…', - cloudNoResults: 'Nenhum projeto correspondente.', - cloudGroupThisWeek: 'Esta semana', cloudGroupThisMonth: 'Este mês', cloudSortLabel: 'Ordenar: {v}', - cloudSortRecent: 'Recentes', - cloudSortOldest: 'Mais antigos', - cloudRefresh: 'Atualizar', - cloudLoginHint: 'Entre na sua conta Genspark para ver os projetos criados na web.', - cloudEmpty: 'Ainda não há projetos na web.', - cloudError: 'Falha ao carregar. Tente novamente mais tarde.', - cloudRetry: 'Tentar novamente', cloudLoadMore: 'Carregar mais', cloudOpenInBrowser: 'Abrir no navegador', navTrash: 'Lixeira', @@ -2262,21 +2103,8 @@ export const strings = { it: { navRecent: 'Recenti', navStarred: 'Preferiti', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Progetti creati sul web con Genspark AI. La modifica continua nel browser: fai clic su un progetto per aprirlo.', - cloudSearchPlaceholder: 'Cerca tra {n} progetti…', - cloudNoResults: 'Nessun progetto corrispondente.', - cloudGroupThisWeek: 'Questa settimana', cloudGroupThisMonth: 'Questo mese', cloudSortLabel: 'Ordina: {v}', - cloudSortRecent: 'Recenti', - cloudSortOldest: 'Meno recenti', - cloudRefresh: 'Aggiorna', - cloudLoginHint: 'Accedi al tuo account Genspark per vedere i progetti creati sul web.', - cloudEmpty: 'Ancora nessun progetto web.', - cloudError: 'Caricamento non riuscito. Riprova più tardi.', - cloudRetry: 'Riprova', cloudLoadMore: 'Carica altri', cloudOpenInBrowser: 'Apri nel browser', navTrash: 'Cestino', @@ -2443,21 +2271,8 @@ export const strings = { pl: { navRecent: 'Ostatnie', navStarred: 'Ulubione', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Projekty utworzone w sieci za pomocą Genspark AI. Edycja jest kontynuowana w przeglądarce — kliknij projekt, aby go otworzyć.', - cloudSearchPlaceholder: 'Szukaj wśród {n} projektów…', - cloudNoResults: 'Brak pasujących projektów.', - cloudGroupThisWeek: 'W tym tygodniu', cloudGroupThisMonth: 'Wcześniej w tym miesiącu', cloudSortLabel: 'Sortuj: {v}', - cloudSortRecent: 'Najnowsze', - cloudSortOldest: 'Najstarsze', - cloudRefresh: 'Odśwież', - cloudLoginHint: 'Zaloguj się na konto Genspark, aby zobaczyć projekty utworzone w sieci.', - cloudEmpty: 'Brak projektów w sieci.', - cloudError: 'Nie udało się wczytać. Spróbuj ponownie później.', - cloudRetry: 'Spróbuj ponownie', cloudLoadMore: 'Wczytaj więcej', cloudOpenInBrowser: 'Otwórz w przeglądarce', navTrash: 'Kosz', @@ -2623,22 +2438,8 @@ export const strings = { nl: { navRecent: 'Recent', navStarred: 'Favorieten', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Projecten gemaakt op het web met Genspark AI. Bewerken gaat verder in je browser — klik op een project om het te openen.', - cloudSearchPlaceholder: 'Zoek in {n} projecten…', - cloudNoResults: 'Geen overeenkomende projecten.', - cloudGroupThisWeek: 'Deze week', cloudGroupThisMonth: 'Eerder deze maand', cloudSortLabel: 'Sorteren: {v}', - cloudSortRecent: 'Recent', - cloudSortOldest: 'Oudste', - cloudRefresh: 'Vernieuwen', - cloudLoginHint: - 'Log in op je Genspark-account om projecten te zien die je op het web hebt gemaakt.', - cloudEmpty: 'Nog geen webprojecten.', - cloudError: 'Laden mislukt. Probeer het later opnieuw.', - cloudRetry: 'Opnieuw proberen', cloudLoadMore: 'Meer laden', cloudOpenInBrowser: 'Openen in browser', navTrash: 'Prullenbak', @@ -2804,21 +2605,8 @@ export const strings = { ms: { navRecent: 'Terkini', navStarred: 'Berbintang', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Projek yang dicipta di web dengan Genspark AI. Penyuntingan diteruskan dalam pelayar — klik projek untuk membukanya.', - cloudSearchPlaceholder: 'Cari {n} projek…', - cloudNoResults: 'Tiada projek sepadan.', - cloudGroupThisWeek: 'Minggu ini', cloudGroupThisMonth: 'Bulan ini', cloudSortLabel: 'Isih: {v}', - cloudSortRecent: 'Terbaru', - cloudSortOldest: 'Terlama', - cloudRefresh: 'Muat semula', - cloudLoginHint: 'Log masuk ke akaun Genspark untuk melihat projek yang anda cipta di web.', - cloudEmpty: 'Belum ada projek web.', - cloudError: 'Gagal memuatkan. Cuba lagi kemudian.', - cloudRetry: 'Cuba lagi', cloudLoadMore: 'Muat lagi', cloudOpenInBrowser: 'Buka dalam pelayar', navTrash: 'Tong sampah', @@ -2984,21 +2772,8 @@ export const strings = { he: { navRecent: 'אחרונים', navStarred: 'מועדפים', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'פרויקטים שנוצרו באינטרנט עם Genspark AI. העריכה נמשכת בדפדפן — לחצו על פרויקט כדי לפתוח אותו.', - cloudSearchPlaceholder: 'חיפוש בין {n} פרויקטים…', - cloudNoResults: 'אין פרויקטים תואמים.', - cloudGroupThisWeek: 'השבוע', cloudGroupThisMonth: 'מוקדם יותר החודש', cloudSortLabel: 'מיון: {v}', - cloudSortRecent: 'החדשים ביותר', - cloudSortOldest: 'הישנים ביותר', - cloudRefresh: 'רענון', - cloudLoginHint: 'התחברו לחשבון Genspark כדי לראות פרויקטים שיצרתם באתר.', - cloudEmpty: 'אין עדיין פרויקטים מהאתר.', - cloudError: 'הטעינה נכשלה. נסו שוב מאוחר יותר.', - cloudRetry: 'נסו שוב', cloudLoadMore: 'טענו עוד', cloudOpenInBrowser: 'פתיחה בדפדפן', navTrash: 'אשפה', @@ -3160,21 +2935,8 @@ export const strings = { hi: { navRecent: 'हाल के', navStarred: 'तारांकित', - navCloud: 'Genspark Projects', - cloudSubtitle: - 'Genspark AI के साथ वेब पर बनाए गए प्रोजेक्ट। संपादन ब्राउज़र में जारी रहता है — खोलने के लिए किसी प्रोजेक्ट पर क्लिक करें।', - cloudSearchPlaceholder: '{n} प्रोजेक्ट खोजें…', - cloudNoResults: 'कोई मिलान वाला प्रोजेक्ट नहीं।', - cloudGroupThisWeek: 'इस सप्ताह', cloudGroupThisMonth: 'इस महीने', cloudSortLabel: 'क्रम: {v}', - cloudSortRecent: 'हाल के', - cloudSortOldest: 'सबसे पुराने', - cloudRefresh: 'रीफ़्रेश', - cloudLoginHint: 'वेब पर बनाए गए प्रोजेक्ट देखने के लिए अपने Genspark खाते में साइन इन करें।', - cloudEmpty: 'अभी तक कोई वेब प्रोजेक्ट नहीं है।', - cloudError: 'लोड नहीं हो सका। बाद में फिर से कोशिश करें।', - cloudRetry: 'फिर से कोशिश करें', cloudLoadMore: 'और लोड करें', cloudOpenInBrowser: 'ब्राउज़र में खोलें', navTrash: 'ट्रैश', @@ -3341,20 +3103,8 @@ export const strings = { 'zh-TW': { navRecent: '最近', navStarred: '收藏', - navCloud: 'Genspark Projects', - cloudSubtitle: '在網頁端用 Genspark AI 建立的專案。編輯在瀏覽器中繼續——點擊任意專案即可開啟。', - cloudSearchPlaceholder: '搜尋 {n} 個專案…', - cloudNoResults: '沒有符合的專案。', - cloudGroupThisWeek: '本週', cloudGroupThisMonth: '本月', cloudSortLabel: '排序:{v}', - cloudSortRecent: '最近', - cloudSortOldest: '最早', - cloudRefresh: '重新整理', - cloudLoginHint: '登入 Genspark 帳號,查看你在網頁端建立的專案。', - cloudEmpty: '還沒有網頁端專案。', - cloudError: '載入失敗,請稍後再試。', - cloudRetry: '重試', cloudLoadMore: '載入更多', cloudOpenInBrowser: '在瀏覽器中開啟', navTrash: '垃圾桶', diff --git a/apps/shell/src/shared/home-api.ts b/apps/shell/src/shared/home-api.ts index 7801d49..fbbc8e6 100644 --- a/apps/shell/src/shared/home-api.ts +++ b/apps/shell/src/shared/home-api.ts @@ -130,37 +130,11 @@ export interface HomeApi { openCreditUsage(): Promise /** Probe the local Hermes gateway's /health (fork onboarding) */ hermesStatus(): Promise<'ok' | 'offline'> - /** locally stored full cloud project list (instant; null when no store or logged out) */ - cloudProjectsCached(): Promise - /** sync the full list from Genspark and return it (1 request when nothing changed); null when the sync failed */ - cloudProjectsSync(): Promise - /** open a cloud project (relative '/agents?id=...' URL) in the default browser */ - openCloudProject(projectUrl: string): Promise } export type CloudProjectKind = 'docs' | 'sheets' | 'slides' -/** a Genspark web project shown in the home cloud section */ -export interface CloudProjectEntry { - projectId: string - title: string - /** module kind derived from the API project type ('docs_agent' → 'docs') */ - kind: CloudProjectKind | 'other' - /** creation time, ms since epoch (0 when unparsable) */ - ctimeMs: number - /** relative genspark.ai URL ('/agents?id=...') */ - projectUrl: string -} - /** full local copy of the cloud project list; filtering/paging are client-side */ -export interface CloudProjectsSnapshot { - /** false when gsk is unavailable (CLI missing or not logged in) */ - available: boolean - /** all projects, newest first */ - projects: CloudProjectEntry[] - /** ms epoch of the last successful sync (0 when never synced) */ - syncedAt: number -} export interface AccountStatus { /** gsk is installed and logged in */ @@ -261,9 +235,6 @@ export const HOME_CHANNELS = { openGenTeam: 'home:open-genteam', openCreditUsage: 'home:open-credit-usage', hermesStatus: 'home:hermes-status', - cloudProjects: 'home:cloud-projects', - cloudProjectsCached: 'home:cloud-projects-cached', - openCloudProject: 'home:open-cloud-project', } as const export const PROJECT_CHANNELS = { diff --git a/apps/shell/tests/cloud-projects.test.ts b/apps/shell/tests/cloud-projects.test.ts deleted file mode 100644 index f547ea7..0000000 --- a/apps/shell/tests/cloud-projects.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { gskListPastProjects, type GskPastProjectsPage } from '@hermesoffice/ai-search' -import { - cloudStoreOwner, - clearCloudProjectsStore, - readCloudProjectsStore, - syncCloudProjects, -} from '../src/main/cloud-projects' - -vi.mock('@hermesoffice/ai-search', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, gskListPastProjects: vi.fn() } -}) - -const listMock = vi.mocked(gskListPastProjects) - -const PROJECTS = [ - { - projectId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', - title: 'Deck A', - kind: 'slides', - ctimeMs: 1_700_000_000_000, - projectUrl: '/agents?id=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', - }, -] - -describe('cloud projects store account binding', () => { - let dir: string - let storePath: string - const envBefore = { key: process.env.GSK_API_KEY, disable: process.env.AI_SEARCH_DISABLE_GSK } - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cloud-store-')) - storePath = join(dir, 'cloud-projects.json') - delete process.env.AI_SEARCH_DISABLE_GSK - process.env.GSK_API_KEY = 'test-key-account-a' - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - if (envBefore.key === undefined) delete process.env.GSK_API_KEY - else process.env.GSK_API_KEY = envBefore.key - if (envBefore.disable === undefined) delete process.env.AI_SEARCH_DISABLE_GSK - else process.env.AI_SEARCH_DISABLE_GSK = envBefore.disable - }) - - const writeStore = (owner: string) => { - writeFileSync( - storePath, - JSON.stringify({ available: true, projects: PROJECTS, syncedAt: 123, owner }), - ) - } - - it('derives the owner tag from the key without containing it', () => { - const tag = cloudStoreOwner() - expect(tag).toMatch(/^[0-9a-f]{16}$/) - expect(tag).not.toContain('test-key') - process.env.GSK_API_KEY = 'test-key-account-b' - expect(cloudStoreOwner()).not.toBe(tag) - }) - - it('serves the store back to the same account', () => { - writeStore(cloudStoreOwner()) - const snap = readCloudProjectsStore(storePath) - expect(snap?.projects.map((p) => p.title)).toEqual(['Deck A']) - expect(snap?.syncedAt).toBe(123) - }) - - it("rejects and deletes another account's store", () => { - writeStore(cloudStoreOwner()) - process.env.GSK_API_KEY = 'test-key-account-b' - expect(readCloudProjectsStore(storePath)).toBeNull() - expect(existsSync(storePath)).toBe(false) - }) - - it('rejects a legacy store without an owner tag', () => { - writeFileSync(storePath, JSON.stringify({ available: true, projects: PROJECTS, syncedAt: 1 })) - expect(readCloudProjectsStore(storePath)).toBeNull() - }) - - it('clearCloudProjectsStore removes the file and tolerates a missing one', () => { - writeStore(cloudStoreOwner()) - clearCloudProjectsStore(storePath) - expect(existsSync(storePath)).toBe(false) - clearCloudProjectsStore(storePath) - }) -}) - -describe('cloud projects sync account isolation', () => { - let dir: string - let storePath: string - const envBefore = { key: process.env.GSK_API_KEY, disable: process.env.AI_SEARCH_DISABLE_GSK } - - const pageFor = (title: string): GskPastProjectsPage => ({ - projects: [ - { - projectId: `id-${title}`, - type: 'slides_agent_git', - title, - ctime: '2026-08-01T00:00:00', - projectUrl: `/agents?id=id-${title}`, - }, - ], - total: 1, - hasMore: false, - }) - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cloud-sync-')) - storePath = join(dir, 'cloud-projects.json') - delete process.env.AI_SEARCH_DISABLE_GSK - process.env.GSK_API_KEY = 'test-key-account-a' - listMock.mockReset() - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - if (envBefore.key === undefined) delete process.env.GSK_API_KEY - else process.env.GSK_API_KEY = envBefore.key - if (envBefore.disable === undefined) delete process.env.AI_SEARCH_DISABLE_GSK - else process.env.AI_SEARCH_DISABLE_GSK = envBefore.disable - }) - - it('writes the store bound to the account that synced', async () => { - listMock.mockResolvedValue(pageFor('Deck A')) - const snap = await syncCloudProjects(storePath) - expect(snap.projects.map((p) => p.title)).toEqual(['Deck A']) - expect(readCloudProjectsStore(storePath)?.projects[0]?.title).toBe('Deck A') - }) - - it('aborts without touching the store when the account switches mid-sync', async () => { - listMock.mockImplementation(async () => { - // the page comes back after the user has switched accounts - process.env.GSK_API_KEY = 'test-key-account-b' - return pageFor('Deck B') - }) - await expect(syncCloudProjects(storePath)).rejects.toThrow(/account changed/) - expect(existsSync(storePath)).toBe(false) - }) - - it('does not share an in-flight sync across accounts', async () => { - const gates: Array<(page: GskPastProjectsPage) => void> = [] - listMock.mockImplementation( - () => new Promise((resolve) => gates.push(resolve)), - ) - - const first = syncCloudProjects(storePath) - expect(syncCloudProjects(storePath)).toBe(first) // same account shares the run - - process.env.GSK_API_KEY = 'test-key-account-b' - const second = syncCloudProjects(storePath) - expect(second).not.toBe(first) - expect(listMock).toHaveBeenCalledTimes(2) - - gates[0]!(pageFor('Deck A')) - gates[1]!(pageFor('Deck B')) - - // the run started for account A sees the key changed and aborts - await expect(first).rejects.toThrow(/account changed/) - const snapB = await second - expect(snapB.projects.map((p) => p.title)).toEqual(['Deck B']) - expect(readCloudProjectsStore(storePath)?.projects[0]?.title).toBe('Deck B') - }) -})