From 9ee9f2e9f4a9cc8bfbb164964c5d218e1a0271b4 Mon Sep 17 00:00:00 2001 From: Gustavo Caetano Date: Tue, 11 Aug 2026 12:13:14 -0300 Subject: [PATCH] feat(shell): remove Genspark web projects view The 'Genspark Projects' sidebar entry listed projects created on the genspark.ai website and opened them in the browser. The fork has no Genspark account flow (auth is the local Hermes gateway), so the view was dead UI that exposed the upstream brand. Removed end to end: - CloudProjectsView component + sidebar entry + cloudMode state (Home.tsx) - cloudProjects*/openCloudProject IPC channels, types and preload bridge - cloud-projects.ts main module and its unit test - 12 i18n keys across all 19 locales - cloud view CSS block (521 lines); Drive cloud sync entry untouched Typecheck global clean, 4k+ tests pass, lint 0 errors. --- apps/shell/src/main/cloud-projects.ts | 162 -------- apps/shell/src/main/index.ts | 20 - apps/shell/src/preload/index.ts | 29 -- apps/shell/src/renderer/src/Home.tsx | 402 +----------------- apps/shell/src/renderer/src/home.css | 521 ------------------------ apps/shell/src/renderer/src/strings.ts | 250 ------------ apps/shell/src/shared/home-api.ts | 29 -- apps/shell/tests/cloud-projects.test.ts | 167 -------- 8 files changed, 4 insertions(+), 1576 deletions(-) delete mode 100644 apps/shell/src/main/cloud-projects.ts delete mode 100644 apps/shell/tests/cloud-projects.test.ts 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') - }) -})