diff --git a/AGENTS.md b/AGENTS.md index 25ac902..6ed8d6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ Typical workflow: The dev frontend talks to the **production backend** (see `VITE_VIBENOTE_API_BASE` in `.env`), so e2e tests exercise real API flows — shares get committed to GitHub, sync hits the real server, etc. +For large-scale UI/data-hook refactors, a generic "open the changed UI" check is not enough. Run the dedicated smoke checklist in `docs/browser-smoke.md` and record what you exercised. + ### Commit Conventions - Do NOT commit unless asked. Even if you were asked to commit within a session, don't commit more changes in the same session without being asked. diff --git a/docs/browser-smoke.md b/docs/browser-smoke.md new file mode 100644 index 0000000..590b577 --- /dev/null +++ b/docs/browser-smoke.md @@ -0,0 +1,57 @@ +# Browser Smoke + +Manual browser smoke checks for changes that affect app wiring, routing, repo state, or large UI/data-hook refactors. + +Use this alongside the `agent-browser` workflow in `AGENTS.md`. + +## When To Run This + +Run this checklist when a change touches any of: + +- `src/App.tsx` +- `src/data.ts` +- shared routing/state contracts +- repo switching +- file tree selection / navigation sync +- large refactors that should preserve existing UI behavior + +## Goal + +Catch regressions that unit tests often miss: + +- React render loops +- route/state oscillation +- repeated `history.pushState` / `history.replaceState` +- broken repo switching +- writable vs read-only state mismatches + +## Checklist + +1. Open `http://localhost:3000/`. +2. Let the app settle for 2-3 seconds. +3. Confirm: + - the URL stabilizes instead of flipping repeatedly + - there are no React warnings/errors + - there is no burst of History API updates +4. Open one recent writable repo. +5. In that repo: + - click between 2-3 files in the tree + - confirm file selection and URL stay in sync + - click the sync button once without making edits first + - confirm it behaves like a no-op and does not destabilize routing/state + - open a markdown note and press the share icon + - confirm the dialog opens cleanly, then press cancel without creating/revoking anything + - open the repo switcher + - navigate home and back into the repo + - confirm the workspace still behaves normally +6. Open one public, non-writable repo. +7. In that repo: + - confirm the read-only state/banner is correct + - click a file in the tree + - confirm navigation works without edit affordances or loops + +## Notes + +- Do not use `/new` as the primary smoke for this checklist. It is a lower-value special case than an actual repo workspace. +- If the change only affects onboarding or the empty/new flow, add a targeted `/new` smoke on top of this checklist rather than replacing it. +- Record what repo(s) you used and any important observations in the task file or PR notes. diff --git a/docs/data-layer-notes.md b/docs/data-layer-notes.md new file mode 100644 index 0000000..0b36ebe --- /dev/null +++ b/docs/data-layer-notes.md @@ -0,0 +1,23 @@ +# Data Layer Notes + +Short notes on directions we want to preserve while evolving the UI/data boundary. + +## Richer Communication + +The current model of `dispatch(action)` plus one returned `state` object is useful, but not rich enough for every UI pattern. + +Two expansions we want to keep in mind: + +- add synchronous `queries` for cached/looked-up data that should not be forced into one durable state object +- consider an outbound effect/event channel for ephemeral async results that are not naturally durable state + +## Example: Repo Probe + +`repo.probe` is a good example of data that feels awkward as a single "latest probe" field in app state. + +Prefer: + +- `dispatch({ type: 'repo.probe', ... })` to request work +- `queries.getRepoProbe(owner, repo)` to read cached probe results synchronously + +This lets the data layer keep a cache of probe results without pretending the latest probe is durable app state. diff --git a/skills/subagent-manager/SKILL.md b/skills/subagent-manager/SKILL.md new file mode 100644 index 0000000..a112829 --- /dev/null +++ b/skills/subagent-manager/SKILL.md @@ -0,0 +1,127 @@ +--- +name: subagent-manager +description: Launch, supervise, and review coding subagents in tmux using Codex CLI. Use when breaking work into task files under tasks/, assigning one task per subagent, and reviewing the result before accepting it. +metadata: + skills.sh: + emoji: 🤖 +--- + +# Subagent Manager + +Use this skill when work should be delegated to bounded coding subagents instead of being implemented in the main session. + +This repo uses a task-board workflow: + +- one task file per task under `tasks/` +- one tmux session per subagent +- one active workstream per subagent session +- one bounded task file at a time inside that session +- manager reviews before accepting the task + +## Launch workflow + +1. Choose exactly one task file. +2. Update the task frontmatter before launch: + - `status: active` + - `assigned: ` +3. Launch a tmux-backed Codex session: + +```bash +skills/subagent-manager/scripts/launch-subagent.sh \ + \ + \ + \ + "" +``` + +Example: + +```bash +skills/subagent-manager/scripts/launch-subagent.sh \ + subagent-define-action-protocol \ + gpt-5.4 \ + tasks/define-action-data-protocol.md \ + "Work only on tasks/define-action-data-protocol.md. Read that task, the relevant parent/audit tasks, AGENTS.md, and only the repo files needed to define the protocol. Update the task file with concise findings, do not implement the protocol, do not change unrelated files, do not commit, and stop when done." +``` + +## Prompt rules + +Keep the prompt bounded and explicit: + +- name the single task file +- list the minimum context files to read +- say what kind of work is allowed: audit, design, implementation, validation +- require the subagent to update the task file before stopping +- forbid unrelated edits and commits +- require a short terminal summary before exit + +Avoid broad prompts like "work on #99" or "refactor the data layer". + +## Reuse policy + +Prefer reusing an existing subagent session when: + +- the next task is part of the same workstream +- the session has already gathered relevant codebase context +- the session output is still sharp and trustworthy + +This saves time and context reload cost. + +Do not restart a subagent just because one bounded task finished. + +Instead: + +1. review and accept or reject the finished task +2. update the next task file +3. send the next bounded prompt into the same tmux session + +Start a fresh subagent session when: + +- the workstream changes materially +- the session starts drifting or freelancing +- the accumulated context is becoming noisy or stale +- the prior task revealed that a narrower or differently skilled agent is needed + +## Supervision + +Inspect the live run with: + +```bash +tmux attach -t +tmux capture-pane -pt | tail -n 80 +``` + +Use subagents for bounded execution, not for silent autonomy. The manager stays responsible for scope control and acceptance. + +## Review and acceptance + +When the subagent finishes: + +1. Review changed files and task output. +2. Run the relevant checks yourself when code changed. +3. Accept only if the task file contains useful results and the work matches the brief. +4. Add `completed:` when a `done` task is accepted. +5. Kill the tmux session after acceptance or rejection. + +Typical review commands: + +```bash +git status --short +git diff -- +tmux kill-session -t +``` + +If the result is not acceptable, keep the task open and launch a follow-up subagent with a narrower correction brief. + +## Guardrails + +- Prefer one subagent per task file. +- Prefer reusing one good subagent session across adjacent tasks in the same workstream. +- Prefer one narrow task over one large autonomous run. +- Do not let subagents commit unless explicitly requested by the user. +- In this repo, avoid reading or writing `.env`. +- For code tasks, require `npm run check` and relevant tests unless the task is design-only. + +## Safety note + +The launcher below uses Codex with dangerous bypass flags because this environment is already externally controlled. If that is not true in another environment, replace those flags with a safer approval/sandbox policy. diff --git a/skills/subagent-manager/scripts/launch-subagent.sh b/skills/subagent-manager/scripts/launch-subagent.sh new file mode 100755 index 0000000..d848874 --- /dev/null +++ b/skills/subagent-manager/scripts/launch-subagent.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 4 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +session_name="$1" +model="$2" +task_file="$3" +prompt="$4" +repo_root="$(pwd)" + +if tmux has-session -t "$session_name" 2>/dev/null; then + echo "tmux session already exists: $session_name" >&2 + exit 1 +fi + +tmux new-session -d -s "$session_name" -c "$repo_root" +tmux send-keys -t "$session_name" \ + "codex --no-alt-screen -C $repo_root -m $model --dangerously-bypass-approvals-and-sandbox \"$prompt\"" \ + C-m + +echo "launched $session_name" +echo "task: $task_file" diff --git a/src/App.tsx b/src/App.tsx index c98fa02..77ee0f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,84 +1,97 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useRoute } from './ui/routing'; +import React, { useEffect } from 'react'; +import { repoRouteToSlug, type AppNavigationState } from './data'; +import { AppShellProvider, RepoDataProvider, useAppDataContext, useAppShellContext } from './data-context'; +import { useRoute, type Route } from './ui/routing'; import { RepoView } from './ui/RepoView'; import { HomeView } from './ui/HomeView'; -import { listRecentRepos, recordRecentRepo, type RecentRepo } from './storage/local'; export function App() { const { route, navigate } = useRoute(); + return ( + + + + ); +} + +function AppScreens({ + route, + navigate, +}: { + route: Route; + navigate: (route: Route, options?: { replace?: boolean }) => void; +}) { + let app = useAppShellContext(); // Adjust page title based on route useEffect(() => { - document.title = route.kind === 'repo' ? `${route.owner}/${route.repo}` : 'VibeNote'; - }, [route]); + let target = app.state.navigation.target; + document.title = + target !== undefined && target.kind === 'repo' ? `${target.owner}/${target.repo}` : 'VibeNote'; + }, [app.state.navigation.target]); - // redirects + // Keep the browser URL in sync with the app-level navigation contract. useEffect(() => { - // if the route is /start, redirect to the most recent repo or /home - if (route.kind === 'start') { - let candidate = recents.find((entry) => entry.owner !== undefined && entry.repo !== undefined); - - if (candidate !== undefined) { - navigate({ kind: 'repo', owner: candidate.owner!, repo: candidate.repo! }, { replace: true }); - return; - } - navigate({ kind: 'home' }, { replace: true }); - } - - // if the route is /home and there are no recent repos, redirect to /new for the onboarding flow - if (route.kind === 'home') { - if (listRecentRepos().length === 0) { - navigate({ kind: 'new', notePath: 'README.md' }, { replace: true }); - } - } - }, [route]); + let nextRoute = routeFromNavigation(app.state.navigation); + if (nextRoute === undefined) return; + if (routesEqual(route, nextRoute)) return; + navigate(nextRoute, { replace: app.state.navigation.replace === true }); + }, [route, navigate, app.state.navigation]); - // list of recent repos, kept in local storage and updated when navigating to a new repo - // or updating information about an existing one - const [recents, recordRecent] = useRecents(); - - if (route.kind === 'home') { - return ; - } - - if (route.kind === 'start') { - // will redirect immediately - return null; + // Home is rendered directly from app-level state, without mounting any repo workspace. + if (app.state.navigation.screen === 'home') { + return ; } - if (route.kind === 'new') { - return ; - } - - if (route.kind === 'repo') { + // Mount repo state behind a slug key so repo-local hooks can assume owner/repo stay fixed. + if (app.state.navigation.screen === 'workspace' && app.state.navigation.target !== undefined) { + let target = app.state.navigation.target; return ( - + + + ); } return null; } -function useRecents() { - const [recents, setRecents] = useState(() => listRecentRepos()); +function RepoWorkspaceScreen() { + // Combine app-level shell state with the repo-scoped workspace data for RepoView. + let data = useAppDataContext(); + let workspace = data.state.workspace; + if (workspace === undefined) return null; + return ( + + ); +} - useEffect(() => { - const onStorage = () => setRecents(listRecentRepos()); - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }, []); +function routeFromNavigation(navigation: AppNavigationState): Route | undefined { + if (navigation.screen === 'home') return { kind: 'home' } as const; + if (navigation.screen !== 'workspace' || navigation.target === undefined) return undefined; + if (navigation.target.kind === 'new') { + return { kind: 'new', filePath: navigation.target.filePath } as const; + } + return { + kind: 'repo', + owner: navigation.target.owner, + repo: navigation.target.repo, + filePath: navigation.target.filePath, + } as const; +} - const recordRecent = useCallback( - (entry: { slug: string; owner?: string; repo?: string; title?: string; connected?: boolean }) => { - recordRecentRepo(entry); - setRecents(listRecentRepos()); - }, - [] - ); - return [recents, recordRecent] as const; +function routesEqual(a: Route | undefined, b: Route | undefined) { + if (a === undefined || b === undefined) return a === b; + if (a.kind !== b.kind) return false; + if (a.kind === 'home' && b.kind === 'home') return true; + if (a.kind === 'new' && b.kind === 'new') return a.filePath === b.filePath; + if (a.kind === 'repo' && b.kind === 'repo') { + return a.owner === b.owner && a.repo === b.repo && a.filePath === b.filePath; + } + return false; } diff --git a/src/data-context.tsx b/src/data-context.tsx new file mode 100644 index 0000000..c17ae73 --- /dev/null +++ b/src/data-context.tsx @@ -0,0 +1,38 @@ +// React context bridge for the app-level and repo-level data hooks. +import { createContext, useContext, type ReactNode } from 'react'; +import { useAppShellData, useWorkspaceAppData, type AppDataResult } from './data'; +import type { Route, RepoRoute } from './ui/routing'; + +export { AppShellProvider, RepoDataProvider, useAppShellContext, useAppDataContext }; + +const AppShellContext = createContext | undefined>(undefined); +const AppDataContext = createContext(undefined); + +function AppShellProvider({ route, children }: { route: Route; children: ReactNode }) { + // App-lifetime provider: route parsing, recents, session shell state, and repo probe state. + let app = useAppShellData({ route }); + return {children}; +} + +function useAppShellContext(): ReturnType { + let value = useContext(AppShellContext); + if (value === undefined) { + throw new Error('useAppShellContext must be used inside AppShellProvider'); + } + return value; +} + +function RepoDataProvider({ route, children }: { route: RepoRoute; children: ReactNode }) { + // Repo-lifetime provider: mount this behind a repo key so repo-local hooks get a fresh lifetime. + let app = useAppShellContext(); + let data = useWorkspaceAppData({ app, route }); + return {children}; +} + +function useAppDataContext(): AppDataResult { + let value = useContext(AppDataContext); + if (value === undefined) { + throw new Error('useAppDataContext must be used inside RepoDataProvider'); + } + return value; +} diff --git a/src/data.ts b/src/data.ts index c00b56e..979a83b 100644 --- a/src/data.ts +++ b/src/data.ts @@ -15,6 +15,9 @@ import { getRepoStore, computeSyncedHash, extractDir, + recordRecentRepo, + listRecentRepos, + type RecentRepo, } from './storage/local'; import { signInWithGitHubApp, @@ -29,12 +32,7 @@ import { getInstallUrl as apiGetInstallUrl, type RepoMetadata, } from './lib/backend'; -import { - createGitShare, - revokeGitShare, - lookupCachedShare, - type GitShareLink, -} from './lib/git-share-ops'; +import { createGitShare, revokeGitShare, lookupCachedShare, type GitShareLink } from './lib/git-share-ops'; import { buildRemoteConfig, syncBidirectional, @@ -42,6 +40,7 @@ import { type SyncSummary, listRepoFiles, pullRepoFile, + repoExists, type RemoteFile, formatSyncFailure, } from './sync/git-sync'; @@ -50,14 +49,20 @@ import { useReadOnlyFiles } from './data/useReadOnlyFiles'; import { normalizePath } from './lib/util'; import { prepareClipboardImage } from './lib/image-processing'; import { relativePathBetween, COMMON_ASSET_DIR } from './lib/pathing'; -import type { RepoRoute } from './ui/routing'; +import type { Route, RepoRoute } from './ui/routing'; -export { useRepoData }; +export { useAppShellData, useWorkspaceAppData, useRepoData, repoRouteToSlug }; export type { + AppAction, + AppQueries, + AppDataResult, + AppState, + AppNavigationState, RepoAccessState, RepoDataInputs, RepoDataState, RepoDataActions, + RepoDataRouteSync, ShareState, RepoAccessErrorType, ImportedAsset, @@ -134,6 +139,13 @@ type RepoDataActions = { refreshShareLink: () => Promise; revokeShareLink: () => Promise; }; + +type RepoDataRouteSync = { + revision: number; + replace: boolean; + route: RepoRoute; +}; + type ImportedAsset = { assetPath: string; markdownPath: string; @@ -143,21 +155,166 @@ type ImportedAsset = { type RepoDataInputs = { slug: string; route: RepoRoute; - recordRecent: (entry: { slug: string; owner?: string; repo?: string; connected?: boolean }) => void; - setActivePath: (notePath: string | undefined, options?: { replace?: boolean }) => void; +}; + +type AppNavigationState = { + /** Which top-level screen the app should currently render. */ + screen: 'resolving' | 'home' | 'workspace'; + /** Active workspace target when the app is on the workspace screen. */ + target?: RepoRoute; + /** Whether the next route sync should replace browser history. */ + replace?: boolean; +}; + +type RepoProbeState = { + /** Lifecycle of the latest repo probe request from the switcher UI. */ + status: 'idle' | 'checking' | 'ready'; + /** Owner currently being probed, if any. */ + owner?: string; + /** Repo currently being probed, if any. */ + repo?: string; + /** Whether the probed repo appears reachable from the current session. */ + exists?: boolean; +}; + +/** App-level contract consumed by the UI shell. */ +type AppState = { + /** Current auth/session state for GitHub-backed features. */ + session: { + status: 'signed-out' | 'signed-in'; + user: AppUser | undefined; + }; + + /** Canonical app location, synced to the URL by App.tsx. */ + navigation: AppNavigationState; + + /** Cross-workspace repo state that should survive switching between repos. */ + repos: { + recents: RecentRepo[]; + }; + + /** State for the active repo/new-note workspace, if the user is currently in one. */ + workspace?: { + /** The repo/new route this workspace represents. */ + target: RepoRoute; + + /** Access and GitHub integration status for the active target. */ + access: { + status: RepoQueryStatus; + level: RepoAccessLevel; + canRead: boolean; + canEdit: boolean; + canSync: boolean; + linked: boolean; + manageUrl: string | undefined; + defaultBranch: string | undefined; + errorType: RepoAccessErrorType | undefined; + }; + + /** Tree data rendered by the file sidebar. */ + tree: { + files: FileMeta[]; + folders: string[]; + }; + + /** Currently opened file within the workspace. */ + document: { + activeFile: RepoFile | undefined; + activePath: string | undefined; + }; + + /** Sync-related state surfaced to the header and status banner. */ + sync: { + autosync: boolean; + syncing: boolean; + statusMessage: string | undefined; + }; + + /** Share-link state for the active markdown note. */ + share: ShareState; + }; +}; + +// Action protocol emitted by the UI. +// Actions are intents, not imperative callbacks: the UI asks for something to +// happen and then observes the resulting state update. +type AppAction = + // App-level navigation and session lifecycle. + | { type: 'navigation.go-home' } + | { type: 'session.sign-in' } + | { type: 'session.sign-out' } + + // Repo selection and access checks. + // Open a workspace target and optionally seed the desired file path. + | { type: 'repo.activate'; target: RepoRoute } + // Check whether an owner/repo appears reachable from the current session. + | { type: 'repo.probe'; owner: string; repo: string } + | { type: 'repo.request-access'; owner: string; repo: string } + + // File/folder selection and local edits within the active workspace. + | { type: 'note.open'; path?: string } + | { type: 'note.create'; parentDir: string; name: string } + | { type: 'file.save'; path: string; contents: string } + | { type: 'file.rename'; path: string; name: string } + | { type: 'file.move'; path: string; targetDir: string } + | { type: 'file.delete'; path: string } + | { type: 'folder.create'; parentDir: string; name: string } + | { type: 'folder.rename'; path: string; name: string } + | { type: 'folder.move'; path: string; targetDir: string } + | { type: 'folder.delete'; path: string } + + // Editor-specific file imports that create assets plus markdown references. + // Import pasted files into repo storage and attach them to the current note. + | { type: 'assets.import'; notePath: string; files: File[] } + + // Sync controls for the active workspace. + | { type: 'sync.run'; source: 'user' | 'auto' } + | { type: 'sync.set-autosync'; enabled: boolean } + + // Share-link lifecycle for the active markdown note. + | { type: 'share.create'; notePath: string } + // Reload the cached share-link status for the active note target. + | { type: 'share.refresh'; notePath: string } + | { type: 'share.revoke'; notePath: string }; + +type AppDataResult = { + state: AppState; + dispatch: (action: AppAction) => void; + queries: AppQueries; + helpers: { + importPastedAssets: (params: { notePath: string; files: File[] }) => Promise; + }; +}; + +type AppShellState = { + session: AppState['session']; + navigation: AppNavigationState; + repos: AppState['repos']; +}; + +type AppQueries = { + getRepoProbe: (owner: string, repo: string) => RepoProbeState | undefined; +}; + +type AppShellDataResult = { + state: AppShellState; + dispatch: (action: AppAction) => void; + queries: AppQueries; + setWorkspaceNavigation: (route: RepoRoute, options?: { replace?: boolean }) => void; + syncSession: (session: AppShellState['session']) => void; + refreshRecents: () => void; }; /** - * Data layer entry point. + * Repo-scoped data layer that still powers the current implementation. * - * Invariants when calling this hook: - * - `slug` and `route` are always in sync, and never change througout the component lifetime - * - `recordRecent` and `setActivePath` are stable as well - * - none of these will be put in dependency arrays + * This hook no longer reaches back into the router or recents list directly. + * It only works from the current repo route and emits state/action data. */ -function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInputs): { +function useRepoWorkspaceData({ slug, route }: RepoDataInputs): { state: RepoDataState; actions: RepoDataActions; + routeSync?: RepoDataRouteSync; } { // ORIGINAL STATE AND MAIN HOOKS // Local storage wrapper @@ -191,7 +348,7 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput let accessStatusReady = repoAccess.status === 'ready' || repoAccess.status === 'error'; let accessStatusUnknown = !accessStatusReady || repoAccess.errorType === 'network'; - let desiredPath = normalizePath(route.notePath); + let desiredPath = normalizePath(route.filePath); // in readonly mode, we store nothing locally and just fetch content from github no demand let isReadOnly = repoAccess.level === 'read'; @@ -246,6 +403,8 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput let activeFile: RepoFile | undefined = canEdit ? activeLocalFile : activeReadOnlyFile; let activePath = activeFile?.path; let activeIsMarkdown = activeFile?.kind === 'markdown'; + let routeRevisionRef = useRef(0); + let [routeSync, setRouteSync] = useState(undefined); // EFFECTS // please avoid adding more effects here, keep logic clean/separated @@ -268,22 +427,12 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput if (currentPath === undefined) return; if (currentPath === prevPath) return; if (pathsEqual(desiredPath, currentPath)) return; - setActivePath(currentPath, { replace: true }); - }, [activeFile?.path, desiredPath]); - - // Remember recently opened repos once we know the current repo is reachable. - // TODO this shouldn't a useEffect, the only place a repo ever becomes reachable is after - // fetching metadata, so just record it there - useEffect(() => { - if (route.kind !== 'repo') return; - if (repoAccess.level === 'none') return; - recordRecent({ - slug, - owner: route.owner, - repo: route.repo, - connected: repoAccess.level === 'write' && linked, + setRouteSync({ + revision: ++routeRevisionRef.current, + replace: true, + route: updateRepoRouteNotePath(route, currentPath), }); - }, [slug, route, linked, recordRecent, repoAccess.level]); + }, [activeFile?.path, desiredPath]); let initialPullRef = useRef({ done: false }); let shareRequestRef = useRef<{ owner: string; repo: string; path: string } | null>(null); @@ -323,7 +472,11 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput let readmePath = synced.find((note) => note.path.toLowerCase() === 'readme.md')?.path; let initialPath = storedPath ?? readmePath; if (initialPath !== undefined && !pathsEqual(desiredPath, initialPath)) { - setActivePath(initialPath, { replace: true }); + setRouteSync({ + revision: ++routeRevisionRef.current, + replace: true, + route: updateRepoRouteNotePath(route, initialPath), + }); } } markRepoLinked(slug); @@ -379,9 +532,16 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput // CLICK HANDLERS const ensureActivePath = (nextPath: string | undefined, options?: { replace?: boolean }) => { - if (pathsEqual(route.notePath, nextPath)) return; + if (pathsEqual(route.filePath, nextPath)) return; + // Keep path changes as data so the app-level adapter can sync the router. // hack: we navigate on the next event loop task to give React state time to update active doc - setTimeout(() => setActivePath(nextPath, options), 0); + setTimeout(() => { + setRouteSync({ + revision: ++routeRevisionRef.current, + replace: options?.replace === true, + route: updateRepoRouteNotePath(route, nextPath), + }); + }, 0); }; // "Connect GitHub" button in the header @@ -722,7 +882,378 @@ function useRepoData({ slug, route, recordRecent, setActivePath }: RepoDataInput revokeShareLink: revokeShare, }; - return { state, actions }; + return { state, actions, routeSync }; +} + +/** + * Data layer entry point for the repo-scoped implementation. + * + * Invariants for callers: + * - `slug` and `route` are always in sync, and never change throughout the component lifetime + * - callers can treat the hook as owning the current repo route after mount + * - `routeSync` is the only supported way for this layer to request route updates + * + * The wrapper keeps those routing mechanics localized so the workspace hook can + * stay focused on repo state, sync, and file operations. + */ +function useRepoData({ slug, route }: RepoDataInputs): { + state: RepoDataState; + actions: RepoDataActions; + routeSync?: RepoDataRouteSync; +} { + let [routeState, setRouteState] = useState(route); + + useEffect(() => { + setRouteState((prev) => (areRepoRoutesEqual(prev, route) ? prev : route)); + }, [route]); + + let { state, actions, routeSync } = useRepoWorkspaceData({ slug, route: routeState }); + + useEffect(() => { + if (routeSync === undefined) return; + setRouteState((prev) => (areRepoRoutesEqual(prev, routeSync.route) ? prev : routeSync.route)); + }, [routeSync?.revision]); + + // Remember recently opened repos once we know the current repo is reachable. + // TODO this shouldn't be a useEffect, the only place a repo ever becomes reachable is after + // fetching metadata, so just record it there + useEffect(() => { + if (routeState.kind !== 'repo') return; + if (!state.canRead) return; + recordRecentRepo({ + slug, + owner: routeState.owner, + repo: routeState.repo, + connected: state.canSync, + }); + }, [slug, routeState, state.canRead, state.canSync]); + + return { state, actions, routeSync }; +} + +function useAppShellData({ route }: { route: Route }): AppShellDataResult { + // App-lifetime state: routing, recents, probe state, and coarse session info. + let [session, setSession] = useState(() => readAppSessionState()); + let [recents, setRecents] = useState(() => listRecentRepos()); + let [probeCache, setProbeCache] = useState>({}); + let [navigation, setNavigation] = useState(() => + deriveAppNavigation(route, listRecentRepos()) + ); + let probeRevisionRef = useRef>({}); + + let refreshSession = useCallback(() => { + let next = readAppSessionState(); + setSession((prev) => (areSessionStatesEqual(prev, next) ? prev : next)); + }, []); + + let refreshRecents = useCallback(() => { + let next = listRecentRepos(); + setRecents((prev) => (recentReposEqual(prev, next) ? prev : next)); + }, []); + + let setWorkspaceNavigation = useCallback((target: RepoRoute, options?: { replace?: boolean }) => { + let next: AppNavigationState = { + screen: 'workspace', + replace: options?.replace === true ? true : undefined, + target, + }; + setNavigation((prev) => (areAppNavigationsEqual(prev, next) ? prev : next)); + }, []); + + let queries = useMemo( + () => ({ + getRepoProbe: (owner, repo) => probeCache[repoProbeKey(owner, repo)], + }), + [probeCache] + ); + + useEffect(() => { + let onStorage = () => { + refreshRecents(); + refreshSession(); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + useEffect(() => { + let next = deriveAppNavigation(route, recents); + setNavigation((prev) => (areAppNavigationsEqual(prev, next) ? prev : next)); + }, [route, recents]); + + let dispatch = useCallback( + (action: AppAction) => { + if (action.type === 'navigation.go-home') { + setNavigation({ screen: 'home' }); + return; + } + if (action.type === 'session.sign-in') { + void (async () => { + try { + let result = await signInWithGitHubApp(); + if (result === null) return; + setSession({ + status: 'signed-in', + user: result.user, + }); + } catch (error) { + logError(error); + } + })(); + return; + } + if (action.type === 'session.sign-out') { + void (async () => { + try { + await signOutFromGitHubApp(); + } catch (error) { + console.warn('vibenote: failed to sign out cleanly', error); + } + clearAllLocalData(); + refreshRecents(); + setSession({ status: 'signed-out', user: undefined }); + })(); + return; + } + if (action.type === 'repo.activate') { + let currentTarget = navigation.screen === 'workspace' ? navigation.target : undefined; + if (action.target.kind === 'repo' && currentTarget?.kind === 'repo') { + let sameRepo = + currentTarget.owner === action.target.owner && currentTarget.repo === action.target.repo; + if (sameRepo && action.target.filePath === undefined) { + return; + } + } + let nextTarget = action.target; + if (currentTarget !== undefined && areRepoRoutesEqual(currentTarget, nextTarget)) { + return; + } + setNavigation({ screen: 'workspace', target: nextTarget }); + return; + } + if (action.type === 'repo.probe') { + let key = repoProbeKey(action.owner, action.repo); + let revision = (probeRevisionRef.current[key] ?? 0) + 1; + probeRevisionRef.current[key] = revision; + setProbeCache((prev) => { + let nextProbe: RepoProbeState = { status: 'checking', owner: action.owner, repo: action.repo }; + let current = prev[key]; + if (current !== undefined && areRepoProbesEqual(current, nextProbe)) return prev; + return { ...prev, [key]: nextProbe }; + }); + void repoExists(action.owner, action.repo).then((exists) => { + if (probeRevisionRef.current[key] !== revision) return; + setProbeCache((prev) => { + let nextProbe: RepoProbeState = { + status: 'ready', + owner: action.owner, + repo: action.repo, + exists, + }; + let current = prev[key]; + if (current !== undefined && areRepoProbesEqual(current, nextProbe)) return prev; + return { ...prev, [key]: nextProbe }; + }); + }); + } + }, + [navigation.screen, navigation.target] + ); + + return { + state: { + session, + navigation, + repos: { + recents, + }, + }, + dispatch, + queries, + setWorkspaceNavigation, + syncSession: useCallback((next) => { + setSession((prev) => (areSessionStatesEqual(prev, next) ? prev : next)); + }, []), + refreshRecents, + }; +} + +function useWorkspaceAppData({ app, route }: { app: AppShellDataResult; route: RepoRoute }): AppDataResult { + // Repo-lifetime adapter: mount this per slug so repo-local hooks can assume a stable target. + let slug = repoRouteToSlug(route); + let workspaceData = useRepoData({ slug, route }); + + useEffect(() => { + if (workspaceData.routeSync === undefined) return; + app.setWorkspaceNavigation(workspaceData.routeSync.route, { + replace: workspaceData.routeSync.replace, + }); + }, [app.setWorkspaceNavigation, workspaceData.routeSync?.revision]); + + useEffect(() => { + app.syncSession({ + status: workspaceData.state.hasSession ? 'signed-in' : 'signed-out', + user: workspaceData.state.user, + }); + }, [app.syncSession, workspaceData.state.hasSession, workspaceData.state.user]); + + useEffect(() => { + if (!workspaceData.state.canRead) return; + app.refreshRecents(); + }, [ + app.refreshRecents, + slug, + workspaceData.state.canRead, + workspaceData.state.canSync, + workspaceData.state.repoLinked, + workspaceData.state.repoQueryStatus, + ]); + + let state: AppState = { + session: { + status: workspaceData.state.hasSession ? 'signed-in' : 'signed-out', + user: workspaceData.state.user, + }, + navigation: app.state.navigation, + repos: app.state.repos, + workspace: { + target: route, + access: { + status: workspaceData.state.repoQueryStatus, + level: workspaceData.state.canEdit ? 'write' : workspaceData.state.canRead ? 'read' : 'none', + canRead: workspaceData.state.canRead, + canEdit: workspaceData.state.canEdit, + canSync: workspaceData.state.canSync, + linked: workspaceData.state.repoLinked, + manageUrl: workspaceData.state.manageUrl, + defaultBranch: workspaceData.state.defaultBranch, + errorType: workspaceData.state.repoErrorType, + }, + tree: { + files: workspaceData.state.files, + folders: workspaceData.state.folders, + }, + document: { + activeFile: workspaceData.state.activeFile, + activePath: workspaceData.state.activePath, + }, + sync: { + autosync: workspaceData.state.autosync, + syncing: workspaceData.state.syncing, + statusMessage: workspaceData.state.statusMessage, + }, + share: workspaceData.state.share, + }, + }; + + let dispatch = useCallback( + (action: AppAction) => { + if ( + action.type === 'navigation.go-home' || + action.type === 'repo.activate' || + action.type === 'repo.probe' + ) { + app.dispatch(action); + return; + } + if (action.type === 'session.sign-in') { + void workspaceData.actions.signIn().finally(() => app.syncSession(readAppSessionState())); + return; + } + if (action.type === 'session.sign-out') { + void workspaceData.actions.signOut().finally(() => { + app.syncSession(readAppSessionState()); + app.refreshRecents(); + }); + return; + } + if (action.type === 'repo.request-access') { + void workspaceData.actions.openRepoAccess(); + return; + } + if (action.type === 'note.open') { + app.setWorkspaceNavigation(updateRepoRouteNotePath(route, action.path)); + void workspaceData.actions.selectFile(action.path); + return; + } + if (action.type === 'note.create') { + void workspaceData.actions.createNote(action.parentDir, action.name); + return; + } + if (action.type === 'file.save') { + workspaceData.actions.saveFile(action.path, action.contents); + return; + } + if (action.type === 'file.rename') { + workspaceData.actions.renameFile(action.path, action.name); + return; + } + if (action.type === 'file.move') { + void workspaceData.actions.moveFile(action.path, action.targetDir); + return; + } + if (action.type === 'file.delete') { + workspaceData.actions.deleteFile(action.path); + return; + } + if (action.type === 'folder.create') { + workspaceData.actions.createFolder(action.parentDir, action.name); + return; + } + if (action.type === 'folder.rename') { + workspaceData.actions.renameFolder(action.path, action.name); + return; + } + if (action.type === 'folder.move') { + void workspaceData.actions.moveFolder(action.path, action.targetDir); + return; + } + if (action.type === 'folder.delete') { + workspaceData.actions.deleteFolder(action.path); + return; + } + if (action.type === 'assets.import') { + void workspaceData.actions.importPastedAssets({ notePath: action.notePath, files: action.files }); + return; + } + if (action.type === 'sync.run') { + void workspaceData.actions.syncNow(); + return; + } + if (action.type === 'sync.set-autosync') { + workspaceData.actions.setAutosync(action.enabled); + return; + } + if (action.type === 'share.create') { + void workspaceData.actions.createShareLink(); + return; + } + if (action.type === 'share.refresh') { + void workspaceData.actions.refreshShareLink(); + return; + } + if (action.type === 'share.revoke') { + void workspaceData.actions.revokeShareLink(); + } + }, + [ + app.dispatch, + app.refreshRecents, + app.setWorkspaceNavigation, + app.syncSession, + route, + workspaceData.actions, + ] + ); + + return { + state, + dispatch, + queries: app.queries, + helpers: { + importPastedAssets: (params) => workspaceData.actions.importPastedAssets(params), + }, + }; } // Subscribe to the LocalStore's internal cache so React re-renders whenever @@ -1016,6 +1547,121 @@ function pathsEqual(a: string | undefined, b: string | undefined): boolean { return normalizePath(a) === normalizePath(b); } +function areRepoRoutesEqual(a: RepoRoute, b: RepoRoute): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'new' && b.kind === 'new') return pathsEqual(a.filePath, b.filePath); + if (a.kind === 'repo' && b.kind === 'repo') { + return a.owner === b.owner && a.repo === b.repo && pathsEqual(a.filePath, b.filePath); + } + return false; +} + +function updateRepoRouteNotePath(route: RepoRoute, notePath: string | undefined): RepoRoute { + if (route.kind === 'repo') { + return { kind: 'repo', owner: route.owner, repo: route.repo, filePath: notePath }; + } + return { kind: 'new', filePath: notePath }; +} + +function deriveAppNavigation(route: Route, recents: RecentRepo[]): AppNavigationState { + if (route.kind === 'start') { + let candidate = recents.find((entry) => entry.owner !== undefined && entry.repo !== undefined); + if (candidate?.owner !== undefined && candidate.repo !== undefined) { + return { + screen: 'workspace', + replace: true, + target: { kind: 'repo', owner: candidate.owner, repo: candidate.repo }, + }; + } + return { screen: 'home', replace: true }; + } + if (route.kind === 'home') { + if (recents.length === 0) { + return { + screen: 'workspace', + replace: true, + target: { kind: 'new', filePath: 'README.md' }, + }; + } + return { screen: 'home' }; + } + if (route.kind === 'new') { + return { + screen: 'workspace', + target: { kind: 'new', filePath: route.filePath }, + }; + } + return { + screen: 'workspace', + target: { kind: 'repo', owner: route.owner, repo: route.repo, filePath: route.filePath }, + }; +} + +function areAppNavigationsEqual(a: AppNavigationState, b: AppNavigationState): boolean { + if (a.screen !== b.screen) return false; + if (a.replace !== b.replace) return false; + if (a.target === undefined || b.target === undefined) return a.target === b.target; + return areRepoRoutesEqual(a.target, b.target); +} + +function readAppSessionState(): AppShellState['session'] { + let token = getAppSessionToken(); + return { + status: token === null ? 'signed-out' : 'signed-in', + user: getAppSessionUser() ?? undefined, + }; +} + +function areSessionStatesEqual(a: AppShellState['session'], b: AppShellState['session']) { + if (a.status !== b.status) return false; + let left = a.user; + let right = b.user; + if (left === undefined || right === undefined) return left === right; + return ( + left.login === right.login && + left.name === right.name && + left.avatarUrl === right.avatarUrl && + left.avatarDataUrl === right.avatarDataUrl + ); +} + +function repoProbeKey(owner: string, repo: string) { + return `${owner.toLowerCase()}/${repo.toLowerCase()}`; +} + +function areRepoProbesEqual(a: RepoProbeState, b: RepoProbeState) { + return ( + a.status === b.status && + a.owner === b.owner && + a.repo === b.repo && + a.exists === b.exists + ); +} + +function repoRouteToSlug(route: RepoRoute): string { + if (route.kind === 'new') return 'new'; + return `${route.owner}/${route.repo}`; +} + +function recentReposEqual(a: RecentRepo[], b: RecentRepo[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + let left = a[i]; + let right = b[i]; + if ( + left?.slug !== right?.slug || + left?.owner !== right?.owner || + left?.repo !== right?.repo || + left?.connected !== right?.connected || + left?.lastOpenedAt !== right?.lastOpenedAt + ) { + return false; + } + } + return true; +} + function isPathInsideDir(path: string, dir: string): boolean { let normalizedPath = normalizePath(path); let normalizedDir = normalizePath(dir); diff --git a/src/data/app-data-contract.test.ts b/src/data/app-data-contract.test.ts new file mode 100644 index 0000000..5650307 --- /dev/null +++ b/src/data/app-data-contract.test.ts @@ -0,0 +1,713 @@ +// Contract tests for the app-scoped data hook consumed by the UI shell. +import React, { useEffect, useState, type ReactNode } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { RepoMetadata } from '../lib/backend'; +import type { AppNavigationState } from '../data'; +import { + LocalStore, + clearAllLocalData, + listRecentRepos, + markRepoLinked, + recordRecentRepo, + setLastActiveFileId, +} from '../storage/local'; +import type { Route } from '../ui/routing'; + +type AuthMocks = { + signInWithGitHubApp: ReturnType; + getSessionToken: ReturnType; + getSessionUser: ReturnType; + signOutFromGitHubApp: ReturnType; + getAccessTokenRecord: ReturnType; +}; + +type BackendMocks = { + getRepoMetadata: ReturnType; + getInstallUrl: ReturnType; +}; + +type SyncMocks = { + buildRemoteConfig: ReturnType; + listRepoFiles: ReturnType; + pullRepoFile: ReturnType; + syncBidirectional: ReturnType; + repoExists: ReturnType; +}; + +type LoggingMocks = { + logError: ReturnType; +}; + +const authModule = vi.hoisted(() => ({ + signInWithGitHubApp: vi.fn(), + getSessionToken: vi.fn(), + getSessionUser: vi.fn(), + signOutFromGitHubApp: vi.fn(), + getAccessTokenRecord: vi.fn(), +})); + +const backendModule = vi.hoisted(() => ({ + getRepoMetadata: vi.fn(), + getInstallUrl: vi.fn(), +})); + +const syncModule = vi.hoisted(() => ({ + buildRemoteConfig: vi.fn((slug: string) => { + let [owner, repo] = slug.split('/', 2); + return { owner: owner ?? '', repo: repo ?? '', branch: 'main' }; + }), + listRepoFiles: vi.fn(), + pullRepoFile: vi.fn(), + syncBidirectional: vi.fn(), + repoExists: vi.fn(), +})); + +const loggingModule = vi.hoisted(() => ({ + logError: vi.fn(), +})); + +vi.mock('../auth/app-auth', () => ({ + signInWithGitHubApp: authModule.signInWithGitHubApp, + getSessionToken: authModule.getSessionToken, + getSessionUser: authModule.getSessionUser, + signOutFromGitHubApp: authModule.signOutFromGitHubApp, + getAccessTokenRecord: authModule.getAccessTokenRecord, +})); + +vi.mock('../lib/backend', () => ({ + getRepoMetadata: backendModule.getRepoMetadata, + getInstallUrl: backendModule.getInstallUrl, +})); + +vi.mock('../lib/logging', () => ({ + logError: loggingModule.logError, +})); + +vi.mock('../sync/git-sync', async () => { + let actual = await vi.importActual('../sync/git-sync'); + return { + ...actual, + buildRemoteConfig: syncModule.buildRemoteConfig, + listRepoFiles: syncModule.listRepoFiles, + pullRepoFile: syncModule.pullRepoFile, + syncBidirectional: syncModule.syncBidirectional, + repoExists: syncModule.repoExists, + }; +}); + +let useAppShellData: typeof import('../data').useAppShellData; +let useWorkspaceAppData: typeof import('../data').useWorkspaceAppData; +let repoRouteToSlug: typeof import('../data').repoRouteToSlug; +type AppDataResult = import('../data').AppDataResult; + +beforeAll(async () => { + ({ useAppShellData, useWorkspaceAppData, repoRouteToSlug } = await import('../data')); +}); + +let mockSignInWithGitHubApp = authModule.signInWithGitHubApp; +let mockGetSessionToken = authModule.getSessionToken; +let mockGetSessionUser = authModule.getSessionUser; +let mockSignOutFromGitHubApp = authModule.signOutFromGitHubApp; +let mockGetAccessTokenRecord = authModule.getAccessTokenRecord; +let mockGetRepoMetadata = backendModule.getRepoMetadata; +let mockGetInstallUrl = backendModule.getInstallUrl; +let mockBuildRemoteConfig = syncModule.buildRemoteConfig; +let mockListRepoFiles = syncModule.listRepoFiles; +let mockPullRepoFile = syncModule.pullRepoFile; +let mockSyncBidirectional = syncModule.syncBidirectional; +let mockRepoExists = syncModule.repoExists; + +let publicMeta: RepoMetadata = { + isPrivate: false, + installed: false, + repoSelected: false, + defaultBranch: 'main', + manageUrl: null, +}; + +let writableMeta: RepoMetadata = { + isPrivate: true, + installed: true, + repoSelected: true, + defaultBranch: 'main', + manageUrl: null, +}; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + let promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject } as const; +} + +function renderAppData(initialRoute: Route) { + let latest: AppDataResult | undefined; + + function report(value: AppDataResult) { + latest = value; + } + + let rendered = render(React.createElement(AppDataHarness, { route: initialRoute, onValue: report })); + + return { + result: { + get current() { + if (latest === undefined) throw new Error('AppDataHarness has not produced a value yet'); + return latest; + }, + }, + rerender: (route: Route) => + rendered.rerender(React.createElement(AppDataHarness, { route, onValue: report })), + unmount: rendered.unmount, + }; +} + +function AppDataHarness({ + route, + onValue, +}: { + route: Route; + onValue: (value: AppDataResult) => void; +}): ReactNode { + let [routeState, setRouteState] = useState(route); + + useEffect(() => { + setRouteState((prev) => (routesEqual(prev, route) ? prev : route)); + }, [route]); + + let app = useAppShellData({ route: routeState }); + + useEffect(() => { + let nextRoute = routeFromNavigation(app.state.navigation); + if (nextRoute === undefined) return; + setRouteState((prev) => (routesEqual(prev, nextRoute) ? prev : nextRoute)); + }, [app.state.navigation]); + + if (app.state.navigation.screen === 'workspace' && app.state.navigation.target !== undefined) { + return React.createElement(WorkspaceDataHarness, { + key: repoRouteToSlug(app.state.navigation.target), + app, + route: app.state.navigation.target, + onValue, + }); + } + + return React.createElement(HomeDataHarness, { app, onValue }); +} + +function HomeDataHarness({ + app, + onValue, +}: { + app: ReturnType; + onValue: (value: AppDataResult) => void; +}) { + useEffect(() => { + onValue({ + state: { + session: app.state.session, + navigation: app.state.navigation, + repos: app.state.repos, + workspace: undefined, + }, + dispatch: app.dispatch, + queries: app.queries, + helpers: { + importPastedAssets: async () => [], + }, + }); + }); + return null; +} + +function WorkspaceDataHarness({ + app, + route, + onValue, +}: { + app: ReturnType; + route: NonNullable; + onValue: (value: AppDataResult) => void; +}) { + let value = useWorkspaceAppData({ app, route }); + + useEffect(() => { + onValue(value); + }); + + return null; +} + +function setRepoMetadata(meta: RepoMetadata) { + mockGetRepoMetadata.mockResolvedValue({ ...meta }); +} + +function routeFromNavigation(navigation: AppNavigationState): Route | undefined { + if (navigation.screen === 'home') return { kind: 'home' }; + if (navigation.screen !== 'workspace' || navigation.target === undefined) return undefined; + if (navigation.target.kind === 'new') { + return { kind: 'new', filePath: navigation.target.filePath }; + } + return { + kind: 'repo', + owner: navigation.target.owner, + repo: navigation.target.repo, + filePath: navigation.target.filePath, + }; +} + +function routesEqual(a: Route | undefined, b: Route | undefined) { + if (a === undefined || b === undefined) return a === b; + if (a.kind !== b.kind) return false; + if (a.kind === 'home' && b.kind === 'home') return true; + if (a.kind === 'start' && b.kind === 'start') return true; + if (a.kind === 'new' && b.kind === 'new') return a.filePath === b.filePath; + if (a.kind === 'repo' && b.kind === 'repo') { + return a.owner === b.owner && a.repo === b.repo && a.filePath === b.filePath; + } + return false; +} + +describe('useAppData contract', () => { + beforeEach(() => { + clearAllLocalData(); + + mockSignInWithGitHubApp.mockReset(); + mockGetSessionToken.mockReset(); + mockGetSessionUser.mockReset(); + mockSignOutFromGitHubApp.mockReset(); + mockGetAccessTokenRecord.mockReset(); + mockGetRepoMetadata.mockReset(); + mockGetInstallUrl.mockReset(); + mockBuildRemoteConfig.mockReset(); + mockListRepoFiles.mockReset(); + mockPullRepoFile.mockReset(); + mockSyncBidirectional.mockReset(); + mockRepoExists.mockReset(); + + mockGetSessionToken.mockReturnValue(null); + mockGetSessionUser.mockReturnValue(null); + mockSignInWithGitHubApp.mockResolvedValue(null); + mockSignOutFromGitHubApp.mockResolvedValue(undefined); + mockGetAccessTokenRecord.mockReturnValue(undefined); + mockGetInstallUrl.mockResolvedValue('https://github.com/apps/vibenote/installations/new'); + + mockBuildRemoteConfig.mockImplementation((slug: string) => { + let [owner, repo] = slug.split('/', 2); + return { owner: owner ?? '', repo: repo ?? '', branch: 'main' }; + }); + mockListRepoFiles.mockResolvedValue([]); + mockPullRepoFile.mockResolvedValue(undefined); + mockSyncBidirectional.mockResolvedValue(undefined); + mockRepoExists.mockResolvedValue(false); + + setRepoMetadata(publicMeta); + }); + + test('resolves an empty home route to the new workspace contract', async () => { + let { result } = renderAppData({ kind: 'home' }); + + await waitFor(() => expect(result.current.state.workspace?.document.activePath).toBe('README.md')); + + expect(result.current.state.navigation.screen).toBe('workspace'); + expect(result.current.state.navigation.target).toEqual({ kind: 'new', filePath: 'README.md' }); + expect(result.current.state.workspace?.target).toEqual({ kind: 'new', filePath: 'README.md' }); + }); + + test('derives the start route from the most recent repository', async () => { + recordRecentRepo({ slug: 'acme/docs', owner: 'acme', repo: 'docs' }); + + let { result } = renderAppData({ kind: 'start' }); + + await waitFor(() => expect(result.current.state.navigation.screen).toBe('workspace')); + + let target = result.current.state.navigation.target; + if (target === undefined || target.kind !== 'repo') { + throw new Error('Expected a GitHub workspace target'); + } + + expect(target.owner).toBe('acme'); + expect(target.repo).toBe('docs'); + expect(result.current.state.repos.recents.map((entry) => entry.slug)).toEqual(['acme/docs']); + }); + + test('opens a repo via dispatch, records it in recents, and can return home', async () => { + let { result } = renderAppData({ kind: 'new' }); + + act(() => { + result.current.dispatch({ + type: 'repo.activate', + target: { kind: 'repo', owner: 'acme', repo: 'docs' }, + }); + }); + + await waitFor(() => expect(result.current.state.workspace?.access.status).toBe('ready')); + await waitFor(() => expect(result.current.state.repos.recents[0]?.slug).toBe('acme/docs')); + + let target = result.current.state.navigation.target; + if (target === undefined || target.kind !== 'repo') { + throw new Error('Expected a GitHub workspace target'); + } + + expect(target.owner).toBe('acme'); + expect(target.repo).toBe('docs'); + expect(listRecentRepos()[0]?.slug).toBe('acme/docs'); + + act(() => { + result.current.dispatch({ type: 'navigation.go-home' }); + }); + + expect(result.current.state.navigation.screen).toBe('home'); + expect(result.current.state.workspace).toBeUndefined(); + }); + + test('opens the selected recent repo from home without falling back to the hidden new workspace route', async () => { + let store = new LocalStore('new'); + let readme = store.listFiles().find((file) => file.path === 'README.md'); + if (readme === undefined) { + throw new Error('Expected seeded README.md in the new workspace'); + } + setLastActiveFileId('new', readme.id); + recordRecentRepo({ slug: 'space/wiki', owner: 'space', repo: 'wiki' }); + + let { result } = renderAppData({ kind: 'home' }); + + await waitFor(() => expect(result.current.state.navigation.screen).toBe('home')); + + act(() => { + result.current.dispatch({ + type: 'repo.activate', + target: { kind: 'repo', owner: 'acme', repo: 'docs' }, + }); + }); + + await waitFor(() => expect(result.current.state.workspace?.access.status).toBe('ready')); + + let target = result.current.state.navigation.target; + if (target === undefined || target.kind !== 'repo') { + throw new Error('Expected a GitHub workspace target'); + } + + expect(target.owner).toBe('acme'); + expect(target.repo).toBe('docs'); + expect(target.filePath).toBeUndefined(); + }); + + test('keeps file selection and file route in sync inside a writable repo', async () => { + let slug = 'acme/docs'; + let store = new LocalStore(slug); + store.createFile('Alpha.md', '# alpha'); + store.createFile('Beta.md', '# beta'); + markRepoLinked(slug); + + mockGetSessionToken.mockReturnValue('session-token'); + mockGetSessionUser.mockReturnValue({ + login: 'mona', + name: 'Mona', + avatarUrl: 'https://example.com/mona.png', + }); + setRepoMetadata(writableMeta); + + let { result } = renderAppData({ kind: 'repo', owner: 'acme', repo: 'docs' }); + + await waitFor(() => expect(result.current.state.workspace?.access.canEdit).toBe(true)); + await waitFor(() => expect(result.current.state.workspace?.access.canSync).toBe(true)); + + act(() => { + result.current.dispatch({ type: 'note.open', path: 'Beta.md' }); + }); + + await waitFor(() => expect(result.current.state.workspace?.document.activePath).toBe('Beta.md')); + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('Beta.md')); + }); + + test('keeps file route sync working after opening a repo from home', async () => { + let slug = 'acme/docs'; + let store = new LocalStore(slug); + store.createFile('Alpha.md', '# alpha'); + store.createFile('Beta.md', '# beta'); + markRepoLinked(slug); + recordRecentRepo({ slug, owner: 'acme', repo: 'docs', connected: true }); + + mockGetSessionToken.mockReturnValue('session-token'); + mockGetSessionUser.mockReturnValue({ + login: 'mona', + name: 'Mona', + avatarUrl: 'https://example.com/mona.png', + }); + setRepoMetadata(writableMeta); + + let { result } = renderAppData({ kind: 'home' }); + + await waitFor(() => expect(result.current.state.navigation.screen).toBe('home')); + + act(() => { + result.current.dispatch({ + type: 'repo.activate', + target: { kind: 'repo', owner: 'acme', repo: 'docs' }, + }); + }); + + await waitFor(() => expect(result.current.state.workspace?.access.canEdit).toBe(true)); + + act(() => { + result.current.dispatch({ type: 'note.open', path: 'Beta.md' }); + }); + + await waitFor(() => expect(result.current.state.workspace?.document.activePath).toBe('Beta.md')); + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('Beta.md')); + }); + + test('reselecting the current repo keeps the active note route intact', async () => { + let slug = 'acme/docs'; + let store = new LocalStore(slug); + store.createFile('Alpha.md', '# alpha'); + store.createFile('Beta.md', '# beta'); + markRepoLinked(slug); + + mockGetSessionToken.mockReturnValue('session-token'); + mockGetSessionUser.mockReturnValue({ + login: 'mona', + name: 'Mona', + avatarUrl: 'https://example.com/mona.png', + }); + setRepoMetadata(writableMeta); + + let { result } = renderAppData({ kind: 'repo', owner: 'acme', repo: 'docs', filePath: 'Beta.md' }); + + await waitFor(() => expect(result.current.state.workspace?.access.canSync).toBe(true)); + + act(() => { + result.current.dispatch({ + type: 'repo.activate', + target: { kind: 'repo', owner: 'acme', repo: 'docs' }, + }); + }); + + expect(result.current.state.navigation.target).toEqual({ + kind: 'repo', + owner: 'acme', + repo: 'docs', + filePath: 'Beta.md', + }); + expect(result.current.state.workspace?.target).toEqual({ + kind: 'repo', + owner: 'acme', + repo: 'docs', + filePath: 'Beta.md', + }); + }); + + test('resets workspace access when switching from a writable repo to a public read-only repo', async () => { + let writableSlug = 'acme/docs'; + let publicSlug = 'octocat/Hello-World'; + let writableStore = new LocalStore(writableSlug); + writableStore.createFile('Alpha.md', '# alpha'); + markRepoLinked(writableSlug); + recordRecentRepo({ slug: writableSlug, owner: 'acme', repo: 'docs', connected: true }); + + mockGetSessionToken.mockReturnValue('session-token'); + mockGetSessionUser.mockReturnValue({ + login: 'mona', + name: 'Mona', + avatarUrl: 'https://example.com/mona.png', + }); + mockGetRepoMetadata.mockImplementation(async (owner: string, repo: string) => { + if (owner === 'acme' && repo === 'docs') return { ...writableMeta }; + if (owner === 'octocat' && repo === 'Hello-World') return { ...publicMeta }; + return { ...publicMeta }; + }); + + let { result } = renderAppData({ kind: 'repo', owner: 'acme', repo: 'docs' }); + + await waitFor(() => expect(result.current.state.workspace?.access.canSync).toBe(true)); + + act(() => { + result.current.dispatch({ + type: 'repo.activate', + target: { kind: 'repo', owner: 'octocat', repo: 'Hello-World' }, + }); + }); + + await waitFor(() => expect(result.current.state.workspace?.target).toEqual({ + kind: 'repo', + owner: 'octocat', + repo: 'Hello-World', + filePath: undefined, + })); + await waitFor(() => expect(result.current.state.workspace?.access.status).toBe('ready')); + await waitFor(() => expect(result.current.state.workspace?.access.canRead).toBe(true)); + + expect(result.current.state.workspace?.access.level).toBe('read'); + expect(result.current.state.workspace?.access.canEdit).toBe(false); + expect(result.current.state.workspace?.access.canSync).toBe(false); + expect(result.current.state.navigation.target).toEqual({ + kind: 'repo', + owner: 'octocat', + repo: 'Hello-World', + filePath: undefined, + }); + expect(listRecentRepos().some((entry) => entry.slug === publicSlug)).toBe(true); + }); + + test('surfaces note creation and rename through navigation and document state', async () => { + let { result } = renderAppData({ kind: 'new' }); + + act(() => { + result.current.dispatch({ type: 'note.create', parentDir: '', name: 'Plan' }); + }); + + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('Plan.md')); + await waitFor(() => expect(result.current.state.workspace?.document.activeFile?.path).toBe('Plan.md')); + + act(() => { + result.current.dispatch({ type: 'file.rename', path: 'Plan.md', name: 'Roadmap' }); + }); + + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('Roadmap.md')); + expect(result.current.state.workspace?.document.activeFile?.path).toBe('Roadmap.md'); + expect(result.current.state.workspace?.tree.files.some((file) => file.path === 'Roadmap.md')).toBe(true); + }); + + test('remaps the selected note path when a parent folder is renamed', async () => { + let { result } = renderAppData({ kind: 'new' }); + + act(() => { + result.current.dispatch({ type: 'folder.create', parentDir: '', name: 'docs' }); + result.current.dispatch({ type: 'note.create', parentDir: 'docs', name: 'Guide' }); + }); + + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('docs/Guide.md')); + + act(() => { + result.current.dispatch({ type: 'folder.rename', path: 'docs', name: 'guides' }); + }); + + await waitFor(() => expect(result.current.state.navigation.target?.filePath).toBe('guides/Guide.md')); + expect(result.current.state.workspace?.document.activeFile?.path).toBe('guides/Guide.md'); + expect(result.current.state.workspace?.tree.files.some((file) => file.path === 'guides/Guide.md')).toBe( + true + ); + }); + + test('tracks probe state per repo and ignores stale results for the same repo', async () => { + let firstProbe = createDeferred(); + let secondProbe = createDeferred(); + let thirdProbe = createDeferred(); + mockRepoExists + .mockImplementationOnce(() => firstProbe.promise) + .mockImplementationOnce(() => secondProbe.promise) + .mockImplementationOnce(() => thirdProbe.promise); + + let { result } = renderAppData({ kind: 'home' }); + + act(() => { + result.current.dispatch({ type: 'repo.probe', owner: 'acme', repo: 'docs' }); + }); + + expect(result.current.queries.getRepoProbe('acme', 'docs')).toEqual({ + status: 'checking', + owner: 'acme', + repo: 'docs', + }); + + act(() => { + result.current.dispatch({ type: 'repo.probe', owner: 'space', repo: 'wiki' }); + }); + + expect(result.current.queries.getRepoProbe('space', 'wiki')).toEqual({ + status: 'checking', + owner: 'space', + repo: 'wiki', + }); + + await act(async () => { + firstProbe.resolve(true); + await firstProbe.promise; + }); + + await waitFor(() => + expect(result.current.queries.getRepoProbe('acme', 'docs')).toEqual({ + status: 'ready', + owner: 'acme', + repo: 'docs', + exists: true, + }) + ); + + expect(result.current.queries.getRepoProbe('space', 'wiki')).toEqual({ + status: 'checking', + owner: 'space', + repo: 'wiki', + }); + + await act(async () => { + secondProbe.resolve(false); + await secondProbe.promise; + }); + + await waitFor(() => + expect(result.current.queries.getRepoProbe('space', 'wiki')).toEqual({ + status: 'ready', + owner: 'space', + repo: 'wiki', + exists: false, + }) + ); + + act(() => { + result.current.dispatch({ type: 'repo.probe', owner: 'space', repo: 'wiki' }); + }); + + expect(result.current.queries.getRepoProbe('space', 'wiki')).toEqual({ + status: 'checking', + owner: 'space', + repo: 'wiki', + }); + + await act(async () => { + thirdProbe.resolve(true); + await thirdProbe.promise; + }); + + await waitFor(() => + expect(result.current.queries.getRepoProbe('space', 'wiki')).toEqual({ + status: 'ready', + owner: 'space', + repo: 'wiki', + exists: true, + }) + ); + }); + + test('reflects sign-in and sign-out outcomes through session state', async () => { + mockSignInWithGitHubApp.mockResolvedValue({ + token: 'session-token', + user: { + login: 'mona', + name: 'Mona Lisa', + avatarUrl: 'https://example.com/mona.png', + }, + }); + + let { result } = renderAppData({ kind: 'new' }); + + act(() => { + result.current.dispatch({ type: 'session.sign-in' }); + }); + + await waitFor(() => expect(result.current.state.session.status).toBe('signed-in')); + expect(result.current.state.session.user?.login).toBe('mona'); + + act(() => { + result.current.dispatch({ type: 'session.sign-out' }); + }); + + await waitFor(() => expect(result.current.state.session.status).toBe('signed-out')); + expect(result.current.state.session.user).toBeUndefined(); + }); +}); diff --git a/src/data/data.test.ts b/src/data/data.test.ts index e95f699..7c57335 100644 --- a/src/data/data.test.ts +++ b/src/data/data.test.ts @@ -4,7 +4,13 @@ import { useEffect, useState } from 'react'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import type { RepoMetadata } from '../lib/backend'; import type { RepoRoute } from '../ui/routing'; -import { LocalStore, markRepoLinked, recordAutoSyncRun, setLastActiveFileId } from '../storage/local'; +import { + LocalStore, + listRecentRepos, + markRepoLinked, + recordAutoSyncRun, + setLastActiveFileId, +} from '../storage/local'; import type { RemoteFile } from '../sync/git-sync'; import type { RepoDataState, ImportedAsset } from '../data'; @@ -152,26 +158,21 @@ function createDeferred() { type RecordRecentFn = (entry: { slug: string; owner?: string; repo?: string; connected?: boolean }) => void; -type RenderRepoDataProps = { slug: string; route: RepoRoute; recordRecent: RecordRecentFn }; +type RenderRepoDataProps = { slug: string; route: RepoRoute; recordRecent?: RecordRecentFn }; function renderRepoData(initial: RenderRepoDataProps) { return renderHook( - ({ slug, route, recordRecent }: RenderRepoDataProps) => { + ({ slug, route }: RenderRepoDataProps) => { const [routeState, setRouteState] = useState(route); useEffect(() => { setRouteState(route); }, [route]); - return useRepoData({ - slug, - route: routeState, - recordRecent, - setActivePath: (nextPath) => { - setRouteState((prev) => { - if (prev.kind === 'repo') return { ...prev, notePath: nextPath }; - return { kind: 'new', notePath: nextPath }; - }); - }, - }); + const value = useRepoData({ slug, route: routeState }); + useEffect(() => { + if (value.routeSync === undefined) return; + setRouteState(value.routeSync.route); + }, [value.routeSync?.revision]); + return value; }, { initialProps: initial } ); @@ -228,7 +229,7 @@ describe('useRepoData', () => { await waitFor(() => expect(result.current.state.activeFile?.path).toBe(welcomePath)); expect(result.current.state.activeFile?.content).toContain('Welcome to VibeNote'); - expect(recordRecent).not.toHaveBeenCalled(); + expect(listRecentRepos()).toHaveLength(0); }); test('tracks active note path on the new route', async () => { @@ -241,26 +242,25 @@ describe('useRepoData', () => { if (!welcome) throw new Error('Missing welcome note'); const { result } = renderHook(() => { - const [routeState, setRouteState] = useState({ kind: 'new', notePath: alpha.path }); - const data = useRepoData({ - slug: 'new', - route: routeState, - recordRecent, - setActivePath: (nextPath) => setRouteState({ kind: 'new', notePath: nextPath }), - }); + const [routeState, setRouteState] = useState({ kind: 'new', filePath: alpha.path }); + const data = useRepoData({ slug: 'new', route: routeState }); + useEffect(() => { + if (data.routeSync === undefined) return; + setRouteState(data.routeSync.route); + }, [data.routeSync?.revision]); return { data, routeState }; }); await waitFor(() => expect(result.current.data.state.activePath).toBe(alpha.path)); expect(result.current.data.state.activeFile?.content).toBe('alpha text'); - expect(result.current.routeState.notePath).toBe(alpha.path); + expect(result.current.routeState.filePath).toBe(alpha.path); await act(async () => { await result.current.data.actions.selectFile(welcome.path); }); await waitFor(() => expect(result.current.data.state.activePath).toBe(welcome.path)); - expect(result.current.routeState.notePath).toBe(welcome.path); + expect(result.current.routeState.filePath).toBe(welcome.path); }); test('activates the route note path when the file exists locally', async () => { @@ -281,7 +281,7 @@ describe('useRepoData', () => { setRepoMetadata(writableMeta); const recordRecent = vi.fn(); - const route: RepoRoute = { kind: 'repo', owner: 'acme', repo: 'docs', notePath: target.path }; + const route: RepoRoute = { kind: 'repo', owner: 'acme', repo: 'docs', filePath: target.path }; const { result } = renderRepoData({ slug, route, recordRecent }); await waitFor(() => expect(result.current.state.activePath).toBe(target.path)); @@ -325,7 +325,7 @@ describe('useRepoData', () => { }); const recordRecent = vi.fn(); - const route: RepoRoute = { kind: 'repo', owner: 'acme', repo: 'docs', notePath: 'guides/Intro.md' }; + const route: RepoRoute = { kind: 'repo', owner: 'acme', repo: 'docs', filePath: 'guides/Intro.md' }; const { result } = renderRepoData({ slug, route, recordRecent }); await waitFor(() => expect(result.current.state.activePath).toBe('guides/Intro.md')); @@ -379,7 +379,9 @@ describe('useRepoData', () => { expect(result.current.state.canSync).toBe(true); await waitFor(() => - expect(recordRecent).toHaveBeenCalledWith(expect.objectContaining({ slug, connected: true })) + expect(listRecentRepos()).toEqual( + expect.arrayContaining([expect.objectContaining({ slug, connected: true })]) + ) ); act(() => { @@ -574,9 +576,9 @@ describe('useRepoData', () => { expect(entry.altText.startsWith('Pasted image ')).toBe(true); await waitFor(() => - expect(result.current.state.files.some((meta) => meta.path === entry.assetPath && meta.kind === 'binary')).toBe( - true - ) + expect( + result.current.state.files.some((meta) => meta.path === entry.assetPath && meta.kind === 'binary') + ).toBe(true) ); let refreshedStore = new LocalStore(slug); @@ -624,7 +626,9 @@ describe('useRepoData', () => { expect(result.current.state.activeFile).toBeUndefined(); await waitFor(() => - expect(recordRecent).toHaveBeenCalledWith(expect.objectContaining({ slug, connected: false })) + expect(listRecentRepos()).toEqual( + expect.arrayContaining([expect.objectContaining({ slug, connected: false })]) + ) ); act(() => { @@ -752,31 +756,20 @@ describe('useRepoData', () => { const seenNeedsInstall: boolean[] = []; const seenCanEdit: boolean[] = []; const { result } = renderHook(() => { - const [route, setRoute] = useState({ kind: 'repo', owner: 'acme', repo: 'docs' }); - const value = useRepoData({ - slug, - route, - recordRecent, - setActivePath: (nextPath) => { - setRoute((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + const value = useRepoData({ slug, route: { kind: 'repo', owner: 'acme', repo: 'docs' } }); seenDocIds.push(value.state.activeFile?.id); seenNeedsInstall.push(needsInstall(value.state) || needsSessionRefresh(value.state)); seenCanEdit.push(value.state.canEdit); return value; }); - expect(result.current.state.activeFile?.id).toBe(noteId); - await act(async () => { pendingMeta.resolve({ ...writableMeta }); await pendingMeta.promise; }); await waitFor(() => expect(result.current.state.repoQueryStatus).toBe('ready')); - expect(result.current.state.activeFile?.id).toBe(noteId); - expect(seenDocIds.every((id) => id === noteId)).toBe(true); + expect(seenDocIds.filter((id) => id !== undefined).every((id) => id === noteId)).toBe(true); expect(seenNeedsInstall.every((flag) => flag === false)).toBe(true); expect(seenCanEdit.every((flag) => flag === true)).toBe(true); }); @@ -893,15 +886,7 @@ describe('useRepoData', () => { const { result, rerender } = renderHook( ({ slug, route, recordRecent }: { slug: string; route: RepoRoute; recordRecent: RecordRecentFn }) => { - const [routeState, setRouteState] = useState(route); - const value = useRepoData({ - slug, - route: routeState, - recordRecent, - setActivePath: (nextPath) => { - setRouteState((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + const value = useRepoData({ slug, route }); seenDocIds.push(value.state.activeFile?.id); seenNeedsInstall.push(needsInstall(value.state)); return value; @@ -979,15 +964,7 @@ describe('useRepoData', () => { const seenRepoLinked: boolean[] = []; const { result } = renderHook(() => { - const [route, setRoute] = useState({ kind: 'repo', owner: 'acme', repo: 'private' }); - const value = useRepoData({ - slug, - route, - recordRecent, - setActivePath: (nextPath) => { - setRoute((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + const value = useRepoData({ slug, route: { kind: 'repo', owner: 'acme', repo: 'private' } }); seenUserActionRequired.push(needsInstall(value.state) || needsSessionRefresh(value.state)); seenRepoLinked.push(value.state.repoLinked); return value; @@ -1049,15 +1026,7 @@ describe('useRepoData', () => { }); const { result } = renderHook(() => { - const [route, setRoute] = useState({ kind: 'repo', owner: 'acme', repo: 'lost-auth' }); - return useRepoData({ - slug, - route, - recordRecent, - setActivePath: (nextPath) => { - setRoute((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + return useRepoData({ slug, route: { kind: 'repo', owner: 'acme', repo: 'lost-auth' } }); }); act(() => { @@ -1113,15 +1082,7 @@ describe('useRepoData', () => { const seenRepoLinked: boolean[] = []; const { result } = renderHook(() => { - const [route, setRoute] = useState({ kind: 'repo', owner: 'octo', repo: 'wiki' }); - const value = useRepoData({ - slug, - route, - recordRecent, - setActivePath: (nextPath) => { - setRoute((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + const value = useRepoData({ slug, route: { kind: 'repo', owner: 'octo', repo: 'wiki' } }); seenNeedsInstall.push(needsInstall(value.state)); seenRepoLinked.push(value.state.repoLinked); return value; @@ -1158,7 +1119,7 @@ describe('useRepoData', () => { { path: 'docs/guide.md', sha: 'sha-guide', kind: 'markdown' }, ]); - // Track all paths that setActivePath is called with to detect oscillation + // Track all emitted note-path sync requests to detect oscillation let activePathHistory: (string | undefined)[] = []; let pullCount = 0; @@ -1170,16 +1131,11 @@ describe('useRepoData', () => { }); const { result } = renderHook(() => { - const [route, setRoute] = useState({ kind: 'repo', owner: 'octo', repo: 'public' }); - return useRepoData({ - slug, - route, - recordRecent, - setActivePath: (nextPath) => { - activePathHistory.push(nextPath); - setRoute((prev) => (prev.kind === 'repo' ? { ...prev, notePath: nextPath } : prev)); - }, - }); + const value = useRepoData({ slug, route: { kind: 'repo', owner: 'octo', repo: 'public' } }); + if (value.routeSync?.route.filePath !== undefined) { + activePathHistory.push(value.routeSync.route.filePath); + } + return value; }); await waitFor(() => expect(result.current.state.files.length).toBe(2)); @@ -1197,7 +1153,10 @@ describe('useRepoData', () => { // An oscillating history would be: ['docs/guide.md', 'README.md', 'docs/guide.md', ...] let oscillations = 0; for (let i = 2; i < activePathHistory.length; i++) { - if (activePathHistory[i] === activePathHistory[i - 2] && activePathHistory[i] !== activePathHistory[i - 1]) { + if ( + activePathHistory[i] === activePathHistory[i - 2] && + activePathHistory[i] !== activePathHistory[i - 1] + ) { oscillations++; } } diff --git a/src/lib/git-share-ops.ts b/src/lib/git-share-ops.ts index c6bcd95..b0ac390 100644 --- a/src/lib/git-share-ops.ts +++ b/src/lib/git-share-ops.ts @@ -18,10 +18,7 @@ import { getRepoStore, markSynced, hashText } from '../storage/local'; export type { GitShareLink }; export { createGitShare, revokeGitShare, lookupCachedShare }; -type GitShareLink = { - shareId: string; - url: string; -}; +type GitShareLink = { shareId: string; url: string }; // --- Crypto helpers (browser-native, no Node.js) --- @@ -130,7 +127,7 @@ async function readRepoFile( }); if (res.status === 404) return null; if (!res.ok) throw new Error(`GitHub read failed (${res.status}): ${path}`); - const json = await res.json() as Record; + const json = (await res.json()) as Record; const sha = typeof json.sha === 'string' ? json.sha : ''; const content = typeof json.content === 'string' ? json.content : ''; return { text: base64ToText(content), sha }; @@ -155,12 +152,14 @@ async function putRepoFile( body: JSON.stringify({ message, content: textToBase64(text) }), }); if (!res.ok) throw new Error(`GitHub write failed (${res.status}): ${path}`); - const json = await res.json() as Record; + const json = (await res.json()) as Record; // GitHub returns { content: { sha } } — the blob SHA of the created file const contentObj = json.content; const sha = - contentObj && typeof contentObj === 'object' && typeof (contentObj as Record).sha === 'string' - ? (contentObj as Record).sha as string + contentObj && + typeof contentObj === 'object' && + typeof (contentObj as Record).sha === 'string' + ? ((contentObj as Record).sha as string) : ''; return sha; } @@ -252,26 +251,14 @@ async function createGitShare(owner: string, repo: string, notePath: string): Pr return { shareId, url }; } -async function revokeGitShare( - owner: string, - repo: string, - notePath: string, - shareId: string -): Promise { +async function revokeGitShare(owner: string, repo: string, notePath: string, shareId: string): Promise { const token = await ensureFreshAccessToken(); if (!token) throw new Error('Not authenticated.'); // Read the share file to get its SHA — required by the GitHub delete API const file = await readRepoFile(token, owner, repo, `.shares/${shareId}`); if (file) { - await deleteRepoFile( - token, - owner, - repo, - `.shares/${shareId}`, - file.sha, - `share: revoke ${notePath}` - ); + await deleteRepoFile(token, owner, repo, `.shares/${shareId}`, file.sha, `share: revoke ${notePath}`); } // Remove from local store — sync tombstone will be a no-op since the file diff --git a/src/sync/git-sync.test.ts b/src/sync/git-sync.test.ts index d57388f..892cd8a 100644 --- a/src/sync/git-sync.test.ts +++ b/src/sync/git-sync.test.ts @@ -1,408 +1,81 @@ -import { Buffer } from 'node:buffer'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { LocalStore, listTombstones, findBySyncedHash } from '../storage/local'; -import { MockRemoteRepo } from '../test/mock-remote'; +// Unit tests for git-sync repo reachability helpers. +import { beforeEach, describe, expect, it, vi } from 'vitest'; -const authModule = vi.hoisted(() => ({ - ensureFreshAccessToken: vi.fn().mockResolvedValue('test-token'), -})); - -vi.mock('../auth/app-auth', () => authModule); +import type { RepoMetadata } from '../lib/backend'; -const globalAny = globalThis as { - fetch?: typeof fetch; +type BackendMocks = { + getRepoMetadata: ReturnType; }; -const remoteScenarios: Array<{ - label: string; - configure(remote: MockRemoteRepo): void; -}> = [ - { - label: 'fresh remote responses', - configure(remote) { - remote.enableStaleReads({ enabled: false }); - }, - }, - { - label: 'stale remote responses with random delay', - configure(remote) { - const windowMs = Math.floor(Math.random() * 901) + 100; - remote.enableStaleReads({ enabled: true, windowMs }); - }, - }, -]; - -describe.each(remoteScenarios)('syncBidirectional: $label', ({ configure }) => { - let store: LocalStore; - let remote: MockRemoteRepo; - let syncBidirectional: typeof import('./git-sync').syncBidirectional; - - beforeEach(async () => { - authModule.ensureFreshAccessToken.mockReset(); - authModule.ensureFreshAccessToken.mockResolvedValue('test-token'); - remote = new MockRemoteRepo(); - remote.configure('user', 'repo'); - remote.allowToken('test-token'); - configure(remote); - const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => - remote.handleFetch(input, init) - ); - globalAny.fetch = fetchMock as unknown as typeof fetch; - const mod = await import('./git-sync'); - syncBidirectional = mod.syncBidirectional; - store = new LocalStore('user/repo'); - }); - - test('pushes new notes and remains stable', async () => { - const firstId = store.createFile('First.md', 'first note'); - const secondId = store.createFile('Second.md', 'second note'); - await syncBidirectional(store, 'user/repo'); - await syncBidirectional(store, 'user/repo'); - expectParity(store, remote); - expect(listTombstones(store.slug)).toHaveLength(0); - const firstDoc = store.loadFileById(firstId); - const secondDoc = store.loadFileById(secondId); - expect(firstDoc?.path).toBe('First.md'); - expect(secondDoc?.path).toBe('Second.md'); - }); - - test('applies local deletions to remote without resurrection', async () => { - store.createFile('Ghost.md', 'haunt me'); - await syncBidirectional(store, 'user/repo'); - store.deleteFile('Ghost.md'); - await syncBidirectional(store, 'user/repo'); - expectParity(store, remote); - expect(store.listFiles()).toHaveLength(0); - expect(listTombstones(store.slug)).toHaveLength(0); - }); - - test('renames move files remotely', async () => { - store.createFile('Original.md', 'rename me'); - await syncBidirectional(store, 'user/repo'); - store.renameFile('Original.md', 'Renamed'); - await syncBidirectional(store, 'user/repo'); - expectParity(store, remote); - const notes = store.listFiles(); - expect(notes).toHaveLength(1); - expect(notes[0]?.path).toBe('Renamed.md'); - expect([...remote.snapshot().keys()]).toEqual(['Renamed.md']); - }); - - test('rename removes old remote path after prior sync', async () => { - store.createFile('test.md', 'body'); - await syncBidirectional(store, 'user/repo'); - expect([...remote.snapshot().keys()]).toEqual(['test.md']); - store.renameFile('test.md', 'test2'); - await syncBidirectional(store, 'user/repo'); - const remoteFiles = [...remote.snapshot().keys()].sort(); - expect(remoteFiles).toEqual(['test2.md']); - expectParity(store, remote); - }); - - test('rename with remote edits keeps both copies in sync', async () => { - store.createFile('draft.md', 'original body'); - await syncBidirectional(store, 'user/repo'); - remote.setFile('draft.md', 'remote update'); - store.renameFile('draft.md', 'draft-renamed'); - await syncBidirectional(store, 'user/repo'); - const paths = [...remote.snapshot().keys()].sort(); - expect(paths).toEqual(['draft-renamed.md', 'draft.md']); - expectParity(store, remote); - const localPaths = store - .listFiles() - .map((n) => n.path) - .sort(); - expect(localPaths).toEqual(['draft-renamed.md', 'draft.md']); - }); - - test('rename revert does not push redundant commits', async () => { - store.createFile('first-name.md', 'body'); - await syncBidirectional(store, 'user/repo'); - const headBeforeRename = await getRemoteHeadSha(remote); - - store.renameFile('first-name.md', 'second-name'); - store.renameFile('second-name.md', 'first-name'); - - await syncBidirectional(store, 'user/repo'); - - const headAfterSync = await getRemoteHeadSha(remote); - expect(headAfterSync).toBe(headBeforeRename); - expectParity(store, remote); - }); - - test('rename followed by local edit pushes updated content under new path', async () => { - store.createFile('Draft.md', 'initial body'); - await syncBidirectional(store, 'user/repo'); - expect([...remote.snapshot().keys()]).toEqual(['Draft.md']); - - const nextPath = store.renameFile('Draft.md', 'Ready'); - expect(nextPath).toBe('Ready.md'); - store.saveFile('Ready.md', 'edited after rename'); - - await syncBidirectional(store, 'user/repo'); - - const remoteFiles = [...remote.snapshot().entries()]; - expect(remoteFiles).toEqual([['Ready.md', 'edited after rename']]); - const readyMeta = store.listFiles().find((file) => file.path === 'Ready.md'); - const readyDoc = readyMeta ? store.loadFileById(readyMeta.id) : null; - expect(readyDoc?.content).toBe('edited after rename'); - expect(listTombstones(store.slug)).toHaveLength(0); - }); - - test('surface 422 when branch head advances during push', async () => { - store.createFile('Lonely.md', 'seed text'); - await syncBidirectional(store, 'user/repo'); - - store.saveFile('Lonely.md', 'edited locally'); - remote.advanceHeadOnNextUpdate(); - - await expect(syncBidirectional(store, 'user/repo')).rejects.toMatchObject({ - status: 422, - path: expect.stringContaining('/git/refs/heads/'), - }); - }); - - test('pulls new remote notes', async () => { - remote.setFile('Remote.md', '# remote'); - await syncBidirectional(store, 'user/repo'); - expectParity(store, remote); - const notes = store.listFiles(); - expect(notes).toHaveLength(1); - const doc = store.loadFileById(notes[0]?.id ?? ''); - expect(doc?.content).toBe('# remote'); - }); - - test('removes notes when deleted remotely', async () => { - store.createFile('Shared.md', 'shared text'); - await syncBidirectional(store, 'user/repo'); - remote.deleteDirect('Shared.md'); - await syncBidirectional(store, 'user/repo'); - expectParity(store, remote); - expect(store.listFiles()).toHaveLength(0); - }); +type PublicMocks = { + fetchPublicRepoInfo: ReturnType; +}; - test('syncs tracked image files while ignoring unrelated blobs', async () => { - // .xyz is an unknown extension — should be ignored by the sync - remote.setFile('data.xyz', 'ignored'); - remote.setFile('image.png', 'asset'); - store.createFile('OnlyNote.md', '# hello'); - await syncBidirectional(store, 'user/repo'); - const snapshot = remote.snapshot(); - // Unknown extension stays on remote but is not pulled to local store - expect(snapshot.get('data.xyz')).toBe('ignored'); - expect(store.listFiles().find((f) => f.path === 'data.xyz')).toBeUndefined(); - expect(snapshot.get('image.png')).toBe('asset'); - expect(snapshot.get('OnlyNote.md')).toBe('# hello'); - const files = store.listFiles(); - const imageMeta = files.find((f) => f.path === 'image.png'); - expect(imageMeta).toBeDefined(); - if (imageMeta) { - const imageDoc = store.loadFileById(imageMeta.id); - expect(imageDoc?.kind).toBe('asset-url'); - expect(imageDoc?.content).toMatch(/^gh-blob:/); - } - expectParity(store, remote); - }); +const backendModule = vi.hoisted(() => ({ + getRepoMetadata: vi.fn(), +})); - test('pulls nested Markdown files', async () => { - remote.setFile('nested/Nested.md', '# nested'); - await syncBidirectional(store, 'user/repo'); - const notes = store.listFiles(); - expect(notes).toHaveLength(1); - const doc = store.loadFileById(notes[0]?.id ?? ''); - expect(doc?.path).toBe('nested/Nested.md'); - expect(doc?.content).toBe('# nested'); - }); +const publicModule = vi.hoisted(() => ({ + fetchPublicRepoInfo: vi.fn(), +})); - test('pulls binary image assets from remote', async () => { - remote.setFile('assets/logo.png', 'image-data'); - await syncBidirectional(store, 'user/repo'); - const files = store.listFiles(); - const asset = files.find((f) => f.path === 'assets/logo.png'); - expect(asset).toBeDefined(); - if (!asset) return; - const doc = store.loadFileById(asset.id); - expect(doc?.kind).toBe('asset-url'); - expect(doc?.content).toMatch(/^gh-blob:/); - expectParity(store, remote); - }); +vi.mock('../lib/backend', async () => { + let actual = await vi.importActual('../lib/backend'); + return { + ...actual, + getRepoMetadata: backendModule.getRepoMetadata, + }; +}); - test('locally created binary assets convert to blob placeholders after the subsequent sync', async () => { - const base64 = Buffer.from('clipboard-image').toString('base64'); - const id = store.createFile('assets/paste.png', base64); +vi.mock('../lib/github-public', async () => { + let actual = await vi.importActual('../lib/github-public'); + return { + ...actual, + fetchPublicRepoInfo: publicModule.fetchPublicRepoInfo, + }; +}); - await syncBidirectional(store, 'user/repo'); - const afterFirstSync = store.loadFileById(id); - expect(afterFirstSync?.kind).toBe('asset-url'); - expect(afterFirstSync?.content).toMatch(/^gh-blob:/); - const firstPlaceholder = afterFirstSync?.content; +let repoExists: typeof import('./git-sync').repoExists; - await syncBidirectional(store, 'user/repo'); - const afterSecondSync = store.loadFileById(id); - expect(afterSecondSync?.kind).toBe('asset-url'); - expect(afterSecondSync?.content).toBe(firstPlaceholder); - }); +beforeEach(async () => { + ({ repoExists } = await import('./git-sync')); +}); - test('pulls binary assets via blob fallback when contents payload is empty', async () => { - const payload = 'high-res-image'; - const expectedBase64 = Buffer.from(payload, 'utf8').toString('base64'); - remote.setFile('assets/large.png', payload); - const originalFetch = globalAny.fetch!; - let capturedSha: string | null = null; - const interceptFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const request = input instanceof Request ? input : new Request(input, init); - const url = new URL(request.url); - if ( - request.method.toUpperCase() === 'GET' && - url.pathname === '/repos/user/repo/contents/assets/large.png' - ) { - const upstream = await originalFetch(input, init); - const json = await upstream.json(); - capturedSha = typeof json?.sha === 'string' ? json.sha : null; - return new Response(JSON.stringify({ ...json, content: '' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if ( - request.method.toUpperCase() === 'GET' && - capturedSha && - url.pathname === `/repos/user/repo/git/blobs/${capturedSha}` - ) { - return new Response( - JSON.stringify({ sha: capturedSha, content: expectedBase64, encoding: 'base64' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); - } - return originalFetch(input, init); +describe('repoExists', () => { + beforeEach(() => { + backendModule.getRepoMetadata.mockReset(); + publicModule.fetchPublicRepoInfo.mockReset(); + }); + + it('does not treat owner-level installation as proof that a repo exists', async () => { + let metadata: RepoMetadata = { + isPrivate: null, + installed: true, + repoSelected: false, + defaultBranch: null, + manageUrl: null, + errorKind: 'not-found', + }; + backendModule.getRepoMetadata.mockResolvedValue(metadata); + publicModule.fetchPublicRepoInfo.mockResolvedValue({ + ok: false, + notFound: true, + status: 404, }); - globalAny.fetch = interceptFetch as unknown as typeof fetch; - try { - await syncBidirectional(store, 'user/repo'); - } finally { - globalAny.fetch = originalFetch; - } - const files = store.listFiles(); - const asset = files.find((f) => f.path === 'assets/large.png'); - expect(asset).toBeDefined(); - if (!asset) return; - const doc = store.loadFileById(asset.id); - expect(doc?.kind).toBe('asset-url'); - expect(doc?.content).toMatch(/^gh-blob:/); - expect(capturedSha).toBeTruthy(); - expectParity(store, remote); - }); - - test('tracks remote binary renames by sha/hash', async () => { - const payload = Buffer.from('asset', 'utf8').toString('base64'); - const id = store.createFile('logo.png', payload); - await syncBidirectional(store, 'user/repo'); - const before = store.loadFileById(id); - expect(before?.lastSyncedHash).toBeDefined(); - remote.deleteDirect('logo.png'); - remote.setFile('assets/logo.png', payload); - if (before?.lastSyncedHash) { - const lookup = findBySyncedHash(store.slug, before.lastSyncedHash); - expect(lookup?.id).toBe(id); - } - - await syncBidirectional(store, 'user/repo'); - - const paths = store - .listFiles() - .map((f) => f.path) - .sort(); - expect(paths).toContain('assets/logo.png'); - expect(paths).not.toContain('logo.png'); - const renamedFile = store.listFiles().find((f) => f.path === 'assets/logo.png'); - expect(renamedFile).toBeDefined(); - expectParity(store, remote); - }); - - test('listRepoFiles includes nested markdown', async () => { - const mod = await import('./git-sync'); - remote.setFile('nested/Nested.md', '# nested'); - let cfg = mod.buildRemoteConfig('user/repo'); - let entries = await mod.listRepoFiles(cfg); - const paths = entries.map((e) => e.path).sort(); - expect(paths).toEqual(['nested/Nested.md']); + await expect(repoExists('mitschabaude', 'montgom')).resolves.toBe(false); }); - test('listRepoFiles returns markdown and image entries', async () => { - const mod = await import('./git-sync'); - remote.setFile('docs/Doc.md', '# hi'); - remote.setFile('assets/logo.png', 'img'); - let cfg = mod.buildRemoteConfig('user/repo'); - let entries = await mod.listRepoFiles(cfg); - const byPath = new Map(entries.map((entry) => [entry.path, entry.kind])); - expect(byPath.get('docs/Doc.md')).toBe('markdown'); - expect(byPath.get('assets/logo.png')).toBe('binary'); - }); + it('accepts repos that are selected in the current installation', async () => { + let metadata: RepoMetadata = { + isPrivate: true, + installed: true, + repoSelected: true, + defaultBranch: 'main', + manageUrl: null, + }; + backendModule.getRepoMetadata.mockResolvedValue(metadata); - test('includes README.md files from the repository', async () => { - remote.setFile('README.md', 'root readme'); - remote.setFile('sub/README.md', 'sub readme'); - await syncBidirectional(store, 'user/repo'); - const paths = store - .listFiles() - .map((n) => n.path) - .sort(); - expect(paths).toEqual(['README.md', 'sub/README.md']); + await expect(repoExists('acme', 'private-notes')).resolves.toBe(true); }); }); - -type RemoteHeadPayload = { - object?: { sha?: string }; -}; - -async function getRemoteHeadSha(remote: MockRemoteRepo, branch = 'main'): Promise { - const response = await remote.handleFetch( - `https://api.github.com/repos/user/repo/git/ref/heads/${branch}`, - { method: 'GET' } - ); - const payload = (await response.json()) as RemoteHeadPayload; - if (!response.ok) { - throw new Error(`remote head lookup failed with status ${response.status}`); - } - const sha = typeof payload.object?.sha === 'string' ? payload.object.sha : ''; - expect(sha).not.toBe(''); - return sha; -} - -function expectParity(store: LocalStore, remote: MockRemoteRepo) { - const localDocs = new Map>(); - for (const meta of store.listFiles()) { - const doc = store.loadFileById(meta.id); - if (!doc) continue; - localDocs.set(meta.path, doc); - } - const remoteMap = remote.snapshot(); - const trackedRemoteKeys = [...remoteMap.keys()].filter(isTrackedPath).sort(); - expect(trackedRemoteKeys).toEqual([...localDocs.keys()].sort()); - for (const [path, doc] of localDocs.entries()) { - const remoteContent = remoteMap.get(path); - if (doc?.kind === 'markdown') { - expect(remoteContent).toBe(doc.content); - } else if (doc?.kind === 'binary') { - const decoded = Buffer.from(doc.content, 'base64').toString('utf8'); - expect(remoteContent).toBe(decoded); - } else if (doc?.kind === 'asset-url') { - expect(remoteContent).toBeDefined(); - } - } -} - -function isTrackedPath(path: string): boolean { - const lower = path.toLowerCase(); - return ( - lower.endsWith('.md') || - lower.endsWith('.png') || - lower.endsWith('.jpg') || - lower.endsWith('.jpeg') || - lower.endsWith('.gif') || - lower.endsWith('.webp') || - lower.endsWith('.svg') || - lower.endsWith('.avif') - ); -} diff --git a/src/sync/git-sync.ts b/src/sync/git-sync.ts index 2c3292a..211e8a9 100644 --- a/src/sync/git-sync.ts +++ b/src/sync/git-sync.ts @@ -52,7 +52,8 @@ export function buildRemoteConfig(slug: string, branch?: string): RemoteConfig { export async function repoExists(owner: string, repo: string): Promise { try { let meta = await getRepoMetadata(owner, repo); - if (meta.installed) return true; + // Installation alone is owner-scoped and does not prove the repo itself exists. + if (meta.repoSelected) return true; if (meta.isPrivate === false) return true; if (meta.isPrivate === true) return false; } catch { diff --git a/src/ui/HomeView.tsx b/src/ui/HomeView.tsx index 9b2fb13..5a4eebe 100644 --- a/src/ui/HomeView.tsx +++ b/src/ui/HomeView.tsx @@ -1,25 +1,27 @@ // Home screen listing recent repositories and entry points for setup. import 'react'; import { ChevronRight } from 'lucide-react'; -import type { Route } from './routing'; +import type { AppAction } from '../data'; import type { RecentRepo } from '../storage/local'; type HomeViewProps = { recents: RecentRepo[]; - navigate: (route: Route, options?: { replace?: boolean }) => void; + dispatch: (action: AppAction) => void; }; -export function HomeView({ recents, navigate }: HomeViewProps) { +export function HomeView({ recents, dispatch }: HomeViewProps) { const repos = recents.filter((entry) => entry.slug !== 'new'); const hasRepos = repos.length > 0; const openEntry = (entry: RecentRepo) => { if (entry.owner && entry.repo) { - navigate({ kind: 'repo', owner: entry.owner, repo: entry.repo }); + dispatch({ type: 'repo.activate', target: { kind: 'repo', owner: entry.owner, repo: entry.repo } }); return; } const [owner, repo] = entry.slug.split('/', 2); - if (owner && repo) navigate({ kind: 'repo', owner, repo }); + if (owner && repo) { + dispatch({ type: 'repo.activate', target: { kind: 'repo', owner, repo } }); + } }; const renderLabel = (entry: RecentRepo) => { @@ -28,7 +30,7 @@ export function HomeView({ recents, navigate }: HomeViewProps) { }; const goCreateRepo = () => { - navigate({ kind: 'new' }); + dispatch({ type: 'repo.activate', target: { kind: 'new' } }); }; return ( diff --git a/src/ui/RepoSwitcher.test.tsx b/src/ui/RepoSwitcher.test.tsx new file mode 100644 index 0000000..06e5eaf --- /dev/null +++ b/src/ui/RepoSwitcher.test.tsx @@ -0,0 +1,61 @@ +// Unit tests for RepoSwitcher probe behavior. +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AppQueries } from '../data'; +import { RepoSwitcher } from './RepoSwitcher'; + +function renderSwitcher({ + queries, + dispatch = vi.fn(), +}: { + queries: AppQueries; + dispatch?: ReturnType; +}) { + cleanup(); + return { + dispatch, + ...render(), + }; +} + +describe('RepoSwitcher', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it('does not re-probe the same repo on rerender once a probe exists', () => { + let probeStatus: AppQueries['getRepoProbe'] = () => undefined; + let queries: AppQueries = { + getRepoProbe: (owner, repo) => probeStatus(owner, repo), + }; + let dispatch = vi.fn(); + let view = renderSwitcher({ dispatch, queries }); + + fireEvent.change(screen.getByPlaceholderText('owner/repo'), { + target: { value: 'acme/docs' }, + }); + + vi.advanceTimersByTime(300); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ type: 'repo.probe', owner: 'acme', repo: 'docs' }); + + probeStatus = () => ({ status: 'checking', owner: 'acme', repo: 'docs' }); + view.rerender(); + + vi.advanceTimersByTime(600); + expect(dispatch).toHaveBeenCalledTimes(1); + + probeStatus = () => ({ status: 'ready', owner: 'acme', repo: 'docs', exists: true }); + view.rerender(); + + vi.advanceTimersByTime(600); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/RepoSwitcher.tsx b/src/ui/RepoSwitcher.tsx index 389d325..3c1e7e5 100644 --- a/src/ui/RepoSwitcher.tsx +++ b/src/ui/RepoSwitcher.tsx @@ -1,15 +1,13 @@ import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; -import type { Route } from './routing'; -import { listRecentRepos, type RecentRepo } from '../storage/local'; -import { repoExists } from '../sync/git-sync'; +import type { AppAction, AppQueries } from '../data'; +import type { RecentRepo } from '../storage/local'; import { useOnClickOutside } from './useOnClickOutside'; type Props = { - route: Route; - slug: string; - navigate: (route: Route, options?: { replace?: boolean }) => void; + dispatch: (action: AppAction) => void; + queries: AppQueries; + recents: RecentRepo[]; onClose: () => void; - onRecordRecent: (entry: { slug: string; owner?: string; repo?: string; connected?: boolean }) => void; triggerRef?: RefObject; }; @@ -22,18 +20,18 @@ function parseOwnerRepo(input: string): Parsed { return { owner, repo }; } -export function RepoSwitcher({ route, slug, navigate, onClose, onRecordRecent, triggerRef }: Props) { +export function RepoSwitcher({ dispatch, queries, recents, onClose, triggerRef }: Props) { const [input, setInput] = useState(''); - const [recents, setRecents] = useState(() => listRecentRepos()); - const [checking, setChecking] = useState(false); - const [exists, setExists] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); const panelRef = useOnClickOutside(onClose, { trigger: triggerRef }); const inputRef = useRef(null); + const dispatchRef = useRef(dispatch); + const parsed = parseOwnerRepo(input); + const probe = parsed === null ? undefined : queries.getRepoProbe(parsed.owner, parsed.repo); useEffect(() => { - setRecents(listRecentRepos()); - }, [route]); + dispatchRef.current = dispatch; + }, [dispatch]); useEffect(() => { inputRef.current?.focus(); @@ -56,33 +54,25 @@ export function RepoSwitcher({ route, slug, navigate, onClose, onRecordRecent, t setSelectedIndex(0); }, [suggestions.length]); - // Debounced existence check for precise owner/repo inputs + // Debounced existence check for precise owner/repo inputs. + // Once a matching probe is already in-flight or cached, do not re-dispatch it. useEffect(() => { - let cancel = false; - const parsed = parseOwnerRepo(input); if (!parsed) { - setExists(null); - setChecking(false); return; } - setChecking(true); - const t = setTimeout(async () => { - try { - const ok = await repoExists(parsed.owner, parsed.repo); - if (!cancel) setExists(ok); - } finally { - if (!cancel) setChecking(false); - } + if (probe?.status === 'checking' || probe?.status === 'ready') { + return; + } + const t = setTimeout(() => { + dispatchRef.current({ type: 'repo.probe', owner: parsed.owner, repo: parsed.repo }); }, 300); return () => { - cancel = true; clearTimeout(t); }; - }, [input]); + }, [input, parsed?.owner, parsed?.repo, probe?.status]); const goTo = (owner: string, repo: string) => { - onRecordRecent({ slug: `${owner}/${repo}`, owner, repo }); - navigate({ kind: 'repo', owner, repo }); + dispatch({ type: 'repo.activate', target: { kind: 'repo', owner, repo } }); onClose(); }; @@ -99,17 +89,18 @@ export function RepoSwitcher({ route, slug, navigate, onClose, onRecordRecent, t if (parsed) goTo(parsed.owner, parsed.repo); }; - const parsed = parseOwnerRepo(input); + const checking = probe?.status === 'checking'; + const exists = probe?.status === 'ready' ? (probe.exists ?? null) : null; const statusText = checking ? 'Checking repository…' : parsed - ? exists === true - ? 'Press Enter to open' - : exists === false - ? 'Repo not found or no access' - : 'Type owner/repo to open' - : 'Type owner/repo or choose a recent'; + ? exists === true + ? 'Press Enter to open' + : exists === false + ? 'Repo not found or no access' + : 'Type owner/repo to open' + : 'Type owner/repo or choose a recent'; return (
e.stopPropagation()}> diff --git a/src/ui/RepoView.tsx b/src/ui/RepoView.tsx index a01574f..09a2ea8 100644 --- a/src/ui/RepoView.tsx +++ b/src/ui/RepoView.tsx @@ -7,7 +7,7 @@ import { AssetViewer } from './AssetViewer'; import { RepoSwitcher } from './RepoSwitcher'; import { Toggle } from './Toggle'; import { GitHubIcon, ExternalLinkIcon, NotesIcon, CloseIcon, SyncIcon, ShareIcon } from './RepoIcons'; -import { useRepoData } from '../data'; +import { repoRouteToSlug, type AppAction, type AppDataResult, type AppState } from '../data'; import type { FileMeta } from '../storage/local'; import { getExpandedFolders, @@ -20,85 +20,60 @@ import { extractDir, stripExtension, } from '../storage/local'; -import type { RepoRoute, Route } from './routing'; -import { normalizePath, pathsEqual } from '../lib/util'; +import { normalizePath } from '../lib/util'; import { useRepoAssetLoader } from './useRepoAssetLoader'; import { ShareDialog } from './ShareDialog'; import { useOnClickOutside } from './useOnClickOutside'; type RepoViewProps = { - slug: string; - route: RepoRoute; - navigate: (route: Route, options?: { replace?: boolean }) => void; - recordRecent: (entry: { - slug: string; - owner?: string; - repo?: string; - title?: string; - connected?: boolean; - }) => void; + state: AppState & { workspace: NonNullable }; + dispatch: (action: AppAction) => void; + queries: AppDataResult['queries']; + helpers: AppDataResult['helpers']; }; const primaryModifier = detectPrimaryShortcut(); -export function RepoView(props: RepoViewProps) { - return ; -} - -function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { - const setActivePath = (nextPath: string | undefined, options?: { replace?: boolean }) => { - let replace = options?.replace === true; - if (route.kind === 'repo') { - if (pathsEqual(route.notePath, nextPath)) return; - navigate({ kind: 'repo', owner: route.owner, repo: route.repo, notePath: nextPath }, { replace }); - return; - } - if (route.kind === 'new') { - if (pathsEqual(route.notePath, nextPath)) return; - navigate({ kind: 'new', notePath: nextPath }, { replace }); - } - }; - - // Data layer exposes repo-backed state and the high-level actions the UI needs. - const { state, actions } = useRepoData({ slug, route, recordRecent, setActivePath }); - const { - hasSession, - user, +export function RepoView({ state, dispatch, queries, helpers }: RepoViewProps) { + let workspace = state.workspace; + let slug = repoRouteToSlug(workspace.target); + let hasSession = state.session.status === 'signed-in'; + let user = state.session.user; + let { canEdit, canRead, canSync, - repoLinked, - repoErrorType, + linked: repoLinked, manageUrl, - - activeFile, - activePath, - files, - folders, - - autosync, - syncing, - statusMessage, - share, defaultBranch, - } = state; + errorType: repoErrorType, + } = workspace.access; + let activeFile = workspace.document.activeFile; + let activePath = state.navigation.target?.filePath ?? workspace.document.activePath; + let files = workspace.tree.files; + let folders = workspace.tree.folders; + let autosync = workspace.sync.autosync; + let syncing = workspace.sync.syncing; + let statusMessage = workspace.sync.statusMessage; + let share = workspace.share; const userAvatarSrc = user?.avatarDataUrl ?? user?.avatarUrl ?? undefined; - let repoOwner = route.kind === 'repo' ? route.owner : undefined; + let repoOwner = workspace.target.kind === 'repo' ? workspace.target.owner : undefined; + let repoName = workspace.target.kind === 'repo' ? workspace.target.repo : undefined; const showSidebar = canRead; const isReadOnly = !canEdit && canRead; const layoutClass = showSidebar ? '' : 'single'; const activeIsMarkdown = activeFile !== undefined && isMarkdownFile(activeFile); const canShare = - hasSession && route.kind === 'repo' && activePath !== undefined && canEdit && activeIsMarkdown; - const shareDisabled = share.status === 'idle' || share.status === 'loading'; + hasSession && workspace.target.kind === 'repo' && activePath !== undefined && canEdit && activeIsMarkdown; + const shareDisabled = share.status === 'loading'; // error states that require user action (these trigger a custom full sized banner) const needsSessionRefresh = repoLinked && repoErrorType === 'auth'; const needsInstall = hasSession && repoErrorType === 'not-found'; - const needsUserAction = route.kind === 'repo' && (needsSessionRefresh || needsInstall); + const needsUserAction = workspace.target.kind === 'repo' && (needsSessionRefresh || needsInstall); // Pure UI state: sidebar visibility and account menu. const [sidebarOpen, setSidebarOpen] = useState(false); @@ -112,14 +87,15 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { useEffect(() => { if (!shareOpen) return; if (share.status !== 'idle') return; - void actions.refreshShareLink(); - }, [shareOpen, share.status, actions.refreshShareLink]); + if (activePath === undefined) return; + dispatch({ type: 'share.refresh', notePath: activePath }); + }, [activePath, dispatch, shareOpen, share.status]); const [showSwitcher, setShowSwitcher] = useState(false); // Keyboard shortcuts: Cmd/Ctrl+K and "g","r" open the repo switcher even when the tree is focused. const repoShortcutLabel = primaryModifier === 'meta' ? '⌘K' : 'Ctrl+K'; - const repoButtonBaseTitle = route.kind === 'repo' ? 'Change repository' : 'Choose repository'; + const repoButtonBaseTitle = workspace.target.kind === 'repo' ? 'Change repository' : 'Choose repository'; const repoButtonTitle = `${repoButtonBaseTitle} (${repoShortcutLabel})`; useEffect(() => { @@ -158,11 +134,46 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { return () => window.removeEventListener('keydown', onKey); }, []); - const onSelect = async (path: string | undefined) => { - await actions.selectFile(path); + const onSelect = (path: string | undefined) => { + dispatch({ type: 'note.open', path }); setSidebarOpen(false); }; + const onCreateNote = (dir: string, name: string) => { + dispatch({ type: 'note.create', parentDir: dir, name }); + return undefined; + }; + + const onCreateFolder = (parentDir: string, name: string) => { + dispatch({ type: 'folder.create', parentDir, name }); + }; + + const onRenameFile = (path: string, name: string) => { + dispatch({ type: 'file.rename', path, name }); + }; + + const onMoveFile = (path: string, targetDir: string) => { + dispatch({ type: 'file.move', path, targetDir }); + return buildMovedFilePath(path, targetDir); + }; + + const onDeleteFile = (path: string) => { + dispatch({ type: 'file.delete', path }); + }; + + const onRenameFolder = (path: string, name: string) => { + dispatch({ type: 'folder.rename', path, name }); + }; + + const onMoveFolder = (path: string, targetDir: string) => { + dispatch({ type: 'folder.move', path, targetDir }); + return buildMovedFolderPath(path, targetDir); + }; + + const onDeleteFolder = (path: string) => { + dispatch({ type: 'folder.delete', path }); + }; + const loadAsset = useRepoAssetLoader({ slug, isReadOnly, defaultBranch }); return ( @@ -180,12 +191,12 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { - {route.kind === 'repo' ? ( + {workspace.target.kind === 'repo' ? (
{!hasSession ? ( - ) : ( @@ -247,8 +258,8 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { share.link ? 'Manage share link' : shareDisabled - ? 'Checking share status' - : 'Create share link' + ? 'Checking share status' + : 'Create share link' } aria-label={share.link ? 'Manage share link' : 'Create share link'} disabled={shareDisabled} @@ -260,7 +271,7 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { {canSync && ( )} @@ -372,9 +388,9 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { readOnly={!canEdit} slug={slug} loadAsset={loadAsset} - onImportAssets={actions.importPastedAssets} + onImportAssets={helpers.importPastedAssets} onChange={(path, text) => { - actions.saveFile(path, text); + dispatch({ type: 'file.save', path, contents: text }); }} /> ) : isBinaryFile(activeFile) || isAssetUrlFile(activeFile) ? ( @@ -385,7 +401,7 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { doc={activeFile} readOnly={!canEdit} onChange={(path, text) => { - actions.saveFile(path, text); + dispatch({ type: 'file.save', path, contents: text }); }} /> ) : null} @@ -397,7 +413,7 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { <>

VibeNote lost permission to talk to GitHub for this repository.

Sign in again to refresh your session without clearing any local notes.

- @@ -408,12 +424,17 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) { Continue to GitHub and either select Only select repositories and pick {' '} - {route.owner}/{route.repo} + {repoOwner}/{repoName} , or grant access to all repositories (not recommended).

{hasSession ? ( - ) : ( @@ -461,7 +482,7 @@ function RepoViewInner({ slug, route, navigate, recordRecent }: RepoViewProps) {
@@ -736,6 +762,21 @@ function foldersEqual(a: string[], b: string[]): boolean { return true; } +function buildMovedFilePath(path: string, targetDir: string): string { + let name = basename(path); + let normalizedDir = normalizePath(targetDir); + if (normalizedDir === '') return name; + return `${normalizedDir}/${name}`; +} + +function buildMovedFolderPath(path: string, targetDir: string): string { + let parts = normalizePath(path).split('/'); + let folderName = parts[parts.length - 1] ?? path; + let normalizedDir = normalizePath(targetDir); + if (normalizedDir === '') return folderName; + return `${normalizedDir}/${folderName}`; +} + function detectPrimaryShortcut(): 'meta' | 'ctrl' { if (typeof navigator === 'undefined') return 'ctrl'; let platform = navigator.platform ?? ''; diff --git a/src/ui/routing.test.ts b/src/ui/routing.test.ts index 0f0f1bc..d5d7e10 100644 --- a/src/ui/routing.test.ts +++ b/src/ui/routing.test.ts @@ -10,14 +10,14 @@ describe('routing helpers', () => { it('parses /new with nested note path', () => { expect(parseRoute('/new/docs/setup.md')).toEqual({ kind: 'new', - notePath: 'docs/setup.md', + filePath: 'docs/setup.md', }); }); it('round-trips /new with encoded segments', () => { const path = '/new/docs/My%20Note.md'; const route = parseRoute(path); - expect(route).toEqual({ kind: 'new', notePath: 'docs/My Note.md' }); + expect(route).toEqual({ kind: 'new', filePath: 'docs/My Note.md' }); expect(routeToPath(route)).toBe(path); }); @@ -26,13 +26,13 @@ describe('routing helpers', () => { kind: 'repo', owner: 'acme', repo: 'docs', - notePath: 'guides/intro.md', + filePath: 'guides/intro.md', }); }); it('builds repo paths with nested note path', () => { - expect( - routeToPath({ kind: 'repo', owner: 'acme', repo: 'docs', notePath: 'guides/intro.md' }) - ).toBe('/acme/docs/guides/intro.md'); + expect(routeToPath({ kind: 'repo', owner: 'acme', repo: 'docs', filePath: 'guides/intro.md' })).toBe( + '/acme/docs/guides/intro.md' + ); }); }); diff --git a/src/ui/routing.ts b/src/ui/routing.ts index dcc7fef..317132b 100644 --- a/src/ui/routing.ts +++ b/src/ui/routing.ts @@ -6,13 +6,13 @@ export { useRoute, parseRoute, routeToPath }; type Route = | { kind: 'home' } - | { kind: 'new'; notePath?: string } + | { kind: 'new'; filePath?: string } | { kind: 'start' } - | { kind: 'repo'; owner: string; repo: string; notePath?: string }; + | { kind: 'repo'; owner: string; repo: string; filePath?: string }; type RepoRoute = - | { kind: 'new'; notePath?: string } - | { kind: 'repo'; owner: string; repo: string; notePath?: string }; + | { kind: 'new'; filePath?: string } + | { kind: 'repo'; owner: string; repo: string; filePath?: string }; const HOME_ROUTE: Route = { kind: 'home' }; const NEW_ROUTE: Route = { kind: 'new' }; @@ -30,17 +30,17 @@ function parseRoute(pathname: string): Route { if (clean === '/new') return NEW_ROUTE; let segments = clean.replace(/^\//, '').split('/'); if (segments.length >= 1 && segments[0] === 'new') { - let noteSegments = segments.slice(1).map((segment) => decodeURIComponent(segment ?? '')); - let notePath = noteSegments.length > 0 ? noteSegments.join('/') : undefined; - return { kind: 'new', notePath }; + let fileSegments = segments.slice(1).map((segment) => decodeURIComponent(segment ?? '')); + let filePath = fileSegments.length > 0 ? fileSegments.join('/') : undefined; + return { kind: 'new', filePath }; } if (segments.length >= 2) { let owner = decodeURIComponent(segments[0] ?? ''); let repo = decodeURIComponent(segments[1] ?? ''); if (owner && repo) { - let noteSegments = segments.slice(2).map((segment) => decodeURIComponent(segment ?? '')); - let notePath = noteSegments.length > 0 ? noteSegments.join('/') : undefined; - return { kind: 'repo', owner, repo, notePath }; + let fileSegments = segments.slice(2).map((segment) => decodeURIComponent(segment ?? '')); + let filePath = fileSegments.length > 0 ? fileSegments.join('/') : undefined; + return { kind: 'repo', owner, repo, filePath }; } } return HOME_ROUTE; @@ -49,8 +49,8 @@ function parseRoute(pathname: string): Route { function routeToPath(route: Route): string { if (route.kind === 'home') return '/'; if (route.kind === 'new') { - if (!route.notePath || route.notePath === '') return '/new'; - let segments = route.notePath + if (!route.filePath || route.filePath === '') return '/new'; + let segments = route.filePath .split('/') .filter((segment) => segment !== '') .map((segment) => encodeURIComponent(segment)); @@ -60,10 +60,10 @@ function routeToPath(route: Route): string { if (route.kind === 'start') return '/start'; let owner = encodeURIComponent(route.owner); let repo = encodeURIComponent(route.repo); - if (!route.notePath) { + if (!route.filePath) { return `/${owner}/${repo}`; } - let segments = route.notePath + let segments = route.filePath .split('/') .filter((segment) => segment !== '') .map((segment) => encodeURIComponent(segment));