diff --git a/README.md b/README.md index 62bb652..c0921f8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ a bug or adds a feature, it can offer to share that change upstream so it ships to every Möbius user — but only with your explicit go-ahead on each contribution. This app is the dashboard for that loop: +- **Sources** — a fetch-free map of the platform and every installed app. + Each project keeps two relationships separate: the recorded update source + versus your live `main`, and the ready/open contribution branches attached + to that project. Tree-delta counts avoid treating installation bookkeeping + commits as source changes; staged, unstaged, untracked, and conflicted files + are shown independently. Filters surface attention, different trees, working + files, active PRs, or aligned projects. - **Stat tiles** — Merged / Open / Ready at a glance. - **Connection card** — connect GitHub right here, in the app. Two paths to the same server-side credential: the GitHub **device flow** (shown when the @@ -63,7 +70,8 @@ on install and update, so the skill always matches the app version. ## Requirements - A Möbius platform version that provides the `/api/github/*` surface - (`/api/github/status` and the read-only `/api/github/graphql`). On an older + (`/api/github/status`, fetch-free `/api/github/source-status`, and the + read-only `/api/github/graphql`). On an older platform the connection card says so and points you to update. If the status request 404s, ask your agent to update the platform. - This app declares `permissions.github_access: true` in its manifest. That diff --git a/api.js b/api.js index adf88bb..caa23e0 100644 --- a/api.js +++ b/api.js @@ -31,6 +31,29 @@ export async function fetchGithubStatus(token) { } } +// Fetch-free local Git metadata for the Sources view. The endpoint is narrow: +// refs, ancestry/diff magnitudes, working-tree counts, and capped path names — +// never source contents or absolute paths. A failure leaves the contribution +// feed usable and lets the Sources view offer an explicit retry. +export async function fetchSourceStatus(token) { + try { + const r = await fetch('/api/github/source-status', { + headers: authHeaders(token), + }) + if (!r.ok) { + return { + ok: false, + unsupported: r.status === 404, + status: r.status, + } + } + const body = await r.json() + return { ok: true, data: body } + } catch { + return { ok: false, offline: true, status: 0 } + } +} + // POSTs one read-only GraphQL document; returns response.data or null. // Callers leave records stale on null — the refresh is best-effort polish, // and GitHub returns null nodes (not errors) for anything inaccessible. diff --git a/index.jsx b/index.jsx index 685b36b..9a20226 100644 --- a/index.jsx +++ b/index.jsx @@ -23,10 +23,16 @@ import { groupRecords, } from './domain.js' import { abandonPrepared, cacheFeed, loadFullDiff, loadLedger, restoreAbandoned } from './storage.js' -import { fetchGithubStatus, fetchLiveStates, submitContribution } from './api.js' +import { + fetchGithubStatus, + fetchLiveStates, + fetchSourceStatus, + submitContribution, +} from './api.js' import { StatTiles } from './ui/StatTiles.jsx' import { ConnectionCard } from './ui/ConnectionCard.jsx' import { Feed } from './ui/Feed.jsx' +import { SourceMap } from './ui/SourceMap.jsx' // The one icon that isn't chrome: the empty-state mark. A branch merging up // into a trunk — the same motif as the app icon, so the two read as kin. @@ -64,7 +70,7 @@ function Header({ appId, fromCache }) {

Contribute

- What your agent has shared upstream + Your local work and its path upstream {fromCache && ( Offline — showing your last synced feed. )} @@ -95,6 +101,13 @@ export default function ContributeApp({ appId, token }) { const [fromCache, setFromCache] = useState(false) const [conn, setConn] = useState({ state: 'unknown' }) const [loading, setLoading] = useState(true) + const [view, setViewState] = useState(() => { + try { return sessionStorage.getItem('contribute-view-v1') || 'sources' } + catch { return 'sources' } + }) + const [sourceSnapshot, setSourceSnapshot] = useState(null) + const [sourceLoading, setSourceLoading] = useState(true) + const [sourceError, setSourceError] = useState('') // Latest records for callbacks (the connect-flow refresh) that must not take // a `records` dependency and re-bind on every ledger change. const recordsRef = useRef(records) @@ -125,6 +138,26 @@ export default function ContributeApp({ appId, token }) { } }, [fetchRefreshed]) + // Local Sources refresh: fetch-free and safe to repeat after an agent edit. + // A 404 specifically means this app source arrived before the companion + // backend route was restarted into the running server, so say that plainly. + const refreshSources = useCallback(async () => { + setSourceLoading(true) + const result = await fetchSourceStatus(token) + if (result.ok) { + setSourceSnapshot(result.data) + setSourceError('') + window.mobius?.signal?.('source_map_viewed', { + source_count: 1 + (result.data?.apps?.length || 0), + }) + } else { + setSourceError(result.unsupported ? 'restart' : 'unavailable') + } + setSourceLoading(false) + }, [token]) + + useEffect(() => { refreshSources() }, [refreshSources]) + // Re-read connection status after an in-app connect/disconnect, and — when we // land connected and have a real (non-cached) ledger — re-run the live // refresh now that GitHub is reachable. Passed to ConnectionCard as onChanged. @@ -210,6 +243,11 @@ export default function ContributeApp({ appId, token }) { } }, [runLiveRefresh]) + const setView = useCallback((next) => { + setViewState(next) + try { sessionStorage.setItem('contribute-view-v1', next) } catch { /* optional */ } + }, []) + // Send = direct PR submit. The platform claims the prepared record, // recomputes the branch diff, safely fast-forwards a stale reusable fork, // pushes to it, opens the PR, and returns the updated ledger record. On a @@ -302,25 +340,64 @@ export default function ContributeApp({ appId, token }) { const stats = useMemo(() => countStats(records), [records]) const groups = useMemo(() => groupRecords(records), [records]) const isEmpty = records.length === 0 + const sourceCount = sourceSnapshot + ? 1 + (sourceSnapshot.apps?.length || 0) + : null + const activeCount = stats.open + stats.ready return (
-
+
- - - {/* Hold the feed area blank until the first load resolves so an empty - ledger doesn't flash the sell-the-loop copy before data arrives. */} - {loading ? null : isEmpty ? : ( - + + + + + {view === 'sources' ? ( + setView('contributions')} /> + ) : ( +
+ + + {/* Hold the feed area blank until the first load resolves so an empty + ledger doesn't flash the sell-the-loop copy before data arrives. */} + {loading ? null : isEmpty ? : ( + + )} +
)}
diff --git a/mobius.json b/mobius.json index 61990d8..a184a40 100644 --- a/mobius.json +++ b/mobius.json @@ -38,6 +38,7 @@ "source_files": [ "theme.js", "domain.js", + "source-map.js", "diff.js", "api.js", "storage.js", @@ -48,6 +49,7 @@ "ui/DiffView.jsx", "ui/FileDiffList.jsx", "ui/ContributionCard.jsx", - "ui/Feed.jsx" + "ui/Feed.jsx", + "ui/SourceMap.jsx" ] } diff --git a/source-map.js b/source-map.js new file mode 100644 index 0000000..dc5bab2 --- /dev/null +++ b/source-map.js @@ -0,0 +1,158 @@ +// Pure Sources-view logic: correlate local repositories with active ledger +// records, derive attention/filter state, and format relationship labels. +// React and I/O stay in ui/SourceMap.jsx + api.js so these rules are cheap to +// exercise under node:test. + +const ACTIVE = new Set(['prepared', 'submitting', 'draft', 'open']) + +function repoKey(value) { + return typeof value === 'string' ? value.trim().toLowerCase() : '' +} + +export function activeContribution(rec) { + return !!rec && ACTIVE.has(rec.status) +} + +export function recordBranch(rec) { + return rec?.branch || rec?.plan?.branch || '' +} + +export function attachSourceProjects(snapshot, records) { + const base = [] + if (snapshot?.platform) base.push(snapshot.platform) + if (Array.isArray(snapshot?.apps)) base.push(...snapshot.apps) + const active = (records || []).filter(activeContribution) + const byRepo = new Map() + for (const rec of active) { + const key = repoKey(rec.repo || rec.plan?.repo) + if (!key) continue + const bucket = byRepo.get(key) || [] + bucket.push(rec) + byRepo.set(key, bucket) + } + + const seen = new Set() + const projects = base.map((project) => { + const key = repoKey(project.canonical_repo) + if (key) seen.add(key) + return decorateProject(project, key ? (byRepo.get(key) || []) : []) + }) + + // A live contribution can outlast an uninstall or refer to a repository not + // installed here. Keep it visible instead of silently dropping it. + for (const [repo, contributions] of byRepo.entries()) { + if (seen.has(repo)) continue + projects.push(decorateProject({ + key: 'external:' + repo, + kind: 'external', + name: repo, + canonical_repo: repo, + available: false, + state: 'unavailable', + branch: null, + base_ref: null, + tree: null, + working: null, + }, contributions)) + } + + return projects.sort((a, b) => { + if (a.kind === 'platform' && b.kind !== 'platform') return -1 + if (b.kind === 'platform' && a.kind !== 'platform') return 1 + if (a.attention !== b.attention) return a.attention ? -1 : 1 + if (a.contributions.length !== b.contributions.length) { + return b.contributions.length - a.contributions.length + } + if (a.different !== b.different) return a.different ? -1 : 1 + return String(a.name).localeCompare(String(b.name)) + }) +} + +function decorateProject(project, contributions) { + const workingFiles = Number(project?.working?.files || 0) + const different = Number(project?.tree?.files || 0) > 0 + const contributionAttention = contributions.some((rec) => rec.needs_attention) + const attention = ( + project?.state === 'conflict' || + project?.state === 'diverged' || + workingFiles > 0 || + contributionAttention + ) + const ready = contributions.filter((rec) => rec.status === 'prepared').length + const open = contributions.length - ready + return { + ...project, + contributions, + contributionCounts: { ready, open }, + different, + workingFiles, + attention, + } +} + +export function sourceSummary(projects) { + return (projects || []).reduce((out, project) => { + out.sources += 1 + out.different += Number(project.different) + out.working += Number(project.workingFiles || 0) + out.active += project.contributions?.length || 0 + out.attention += Number(project.attention) + return out + }, { sources: 0, different: 0, working: 0, active: 0, attention: 0 }) +} + +export function projectMatchesFilter(project, filter) { + if (filter === 'attention') return project.attention + if (filter === 'different') return project.different + if (filter === 'working') return project.workingFiles > 0 + if (filter === 'prs') return project.contributions.length > 0 + if (filter === 'aligned') return project.state === 'aligned' + return true +} + +export function projectStatus(project) { + if (project.kind === 'external') return { label: 'Contribution only', tone: 'quiet' } + if (!project.available) return { label: 'Not tracked', tone: 'quiet' } + if (project.state === 'conflict') return { label: 'Update conflict', tone: 'danger' } + if (project.contributions.some((rec) => rec.needs_attention)) { + return { label: 'Needs attention', tone: 'danger' } + } + if (project.workingFiles > 0) { + return { label: project.workingFiles + ' working', tone: 'warn' } + } + if (project.state === 'diverged') { + return { label: 'Both sides changed', tone: 'warn' } + } + if (project.detached || (project.branch && project.branch !== 'main')) { + return { label: project.detached ? 'Detached' : project.branch, tone: 'warn' } + } + if (project.state === 'incoming') return { label: 'Update available', tone: 'accent' } + if (project.state === 'customized') return { label: 'Different', tone: 'accent' } + if (project.state === 'local_only') return { label: 'Local only', tone: 'quiet' } + return { label: 'Aligned', tone: 'ok' } +} + +export function contributionRelationship(rec, project) { + const plan = rec?.plan || {} + const reviewedHead = plan.head_sha || '' + const remoteHead = rec?.last_submit_push_sha || reviewedHead + const base = plan.base_sha || rec?.last_submit_upstream_sha || '' + const local = project?.head_sha || '' + if (remoteHead && local && remoteHead === local) return 'Matches your main' + if (base && local && base === local) return 'Based on your main' + if (rec?.status === 'prepared') { + return base ? 'Private · based on ' + base.slice(0, 7) : 'Private · not public' + } + if (remoteHead && remoteHead !== reviewedHead) { + return 'Published as ' + remoteHead.slice(0, 7) + } + return base ? 'Based on ' + base.slice(0, 7) : 'Relationship unknown' +} + +export function formatSourceDelta(project) { + const tree = project?.tree + if (!tree?.available) return 'Source comparison unavailable' + if (!tree.files) return 'Source trees match' + const files = tree.files + (tree.files === 1 ? ' file differs' : ' files differ') + return files + ' · +' + tree.insertions + ' −' + tree.deletions +} diff --git a/test/source-map.test.mjs b/test/source-map.test.mjs new file mode 100644 index 0000000..c2cbe40 --- /dev/null +++ b/test/source-map.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + attachSourceProjects, + contributionRelationship, + formatSourceDelta, + projectMatchesFilter, + projectStatus, + sourceSummary, +} from '../source-map.js' + +const snapshot = { + platform: { + key: 'platform', kind: 'platform', name: 'Möbius', available: true, + canonical_repo: 'mobius-os/mobius', state: 'diverged', ahead: 23, behind: 2, + head_sha: 'aaaaaaaa', base_sha: 'bbbbbbbb', + tree: { available: true, files: 4, insertions: 12, deletions: 3 }, + working: { available: true, files: 0 }, + }, + apps: [{ + key: 'app:80', kind: 'app', name: 'Contribute', available: true, + canonical_repo: 'mobius-os/app-contribute', state: 'aligned', ahead: 1, behind: 0, + head_sha: 'cccccccc', base_sha: 'dddddddd', + tree: { available: true, files: 0, insertions: 0, deletions: 0 }, + working: { available: true, files: 0 }, + }], +} + +test('joins only active contribution records to their source project', () => { + const records = [ + { id: 'ready', repo: 'MOBIUS-OS/MOBIUS', status: 'prepared', plan: {} }, + { id: 'open', repo: 'mobius-os/app-contribute', status: 'open', plan: {} }, + { id: 'done', repo: 'mobius-os/mobius', status: 'merged', plan: {} }, + ] + const projects = attachSourceProjects(snapshot, records) + assert.equal(projects[0].name, 'Möbius') + assert.deepEqual(projects[0].contributions.map((r) => r.id), ['ready']) + assert.deepEqual(projects[1].contributions.map((r) => r.id), ['open']) + assert.equal(sourceSummary(projects).active, 2) +}) + +test('tree equality wins over bookkeeping-only ahead history', () => { + const projects = attachSourceProjects(snapshot, []) + const contribute = projects.find((p) => p.name === 'Contribute') + assert.equal(contribute.ahead, 1) + assert.equal(contribute.different, false) + assert.equal(projectStatus(contribute).label, 'Aligned') + assert.equal(projectMatchesFilter(contribute, 'different'), false) +}) + +test('active records for an uninstalled repo stay visible', () => { + const projects = attachSourceProjects(snapshot, [{ + id: 'other', repo: 'mobius-os/app-gone', status: 'open', title: 'Still open', + }]) + const external = projects.find((p) => p.kind === 'external') + assert.equal(external.canonical_repo, 'mobius-os/app-gone') + assert.equal(external.contributions.length, 1) + assert.equal(projectStatus(external).label, 'Contribution only') +}) + +test('attention and relationship labels preserve real PR head topology', () => { + const project = attachSourceProjects(snapshot, [{ + id: 'pr', repo: 'mobius-os/mobius', status: 'open', needs_attention: true, + last_submit_push_sha: 'eeeeeeee', + plan: { base_sha: 'bbbbbbbb', head_sha: 'ffffffff' }, + }])[0] + assert.equal(projectStatus(project).label, 'Needs attention') + assert.equal(contributionRelationship(project.contributions[0], project), 'Published as eeeeeee') +}) + +test('formats authoritative endpoint tree delta', () => { + assert.equal(formatSourceDelta(snapshot.platform), '4 files differ · +12 −3') + assert.equal(formatSourceDelta(snapshot.apps[0]), 'Source trees match') +}) diff --git a/theme.js b/theme.js index 62adf6a..51937e2 100644 --- a/theme.js +++ b/theme.js @@ -76,6 +76,351 @@ export const CSS = ` .co-offline-note { display: block; margin: 8px 0 0; font-size: 12px; color: var(--muted); } +/* Sources / Contributions top-level split. Sources gets a wider desktop + canvas; Contributions keeps the original 680px reading measure. */ +.co-page.is-sources { width: min(100%, 1040px); } +.co-tabs { + display: grid; grid-template-columns: 1fr 1fr; gap: 3px; + margin: 13px 0 16px; padding: 3px; + border: 1px solid var(--border); border-radius: 12px; + background: var(--surface2, var(--surface)); +} +.co-tabs button { + min-height: 40px; padding: 8px 12px; border: 0; border-radius: 9px; + background: transparent; color: var(--muted); font: inherit; + font-size: 13px; font-weight: 650; cursor: pointer; +} +.co-tabs button.is-active { + background: var(--surface); color: var(--text); + box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent); +} +.co-tabs button span { + display: inline-flex; align-items: center; justify-content: center; + min-width: 20px; height: 20px; margin-left: 5px; padding: 0 6px; + border-radius: 999px; background: color-mix(in srgb, var(--accent) 13%, transparent); + color: var(--accent); font-size: 11px; font-variant-numeric: tabular-nums; +} + +/* Source map overview. The two rails have distinct jobs: code relationship + (update source → local main → working files) and share relationship + (contribution branches attached below in the detail panel). */ +.co-sources { padding-bottom: 24px; } +.co-sources-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; + margin: 2px 0 12px; +} +.co-sources-head h2 { margin: 0; font-size: 17px; line-height: 1.3; } +.co-sources-head p { + max-width: 620px; margin: 4px 0 0; color: var(--muted); + font-size: 12.5px; line-height: 1.5; +} +.co-sources-fresh { + flex: 0 0 auto; display: flex; align-items: center; gap: 9px; + color: var(--muted); font-size: 11.5px; white-space: nowrap; +} +.co-source-note, +.co-source-error, +.co-source-unavailable { + border: 1px solid var(--border); border-radius: 11px; + background: var(--surface); color: var(--muted); + font-size: 12.5px; line-height: 1.5; +} +.co-source-note { margin-bottom: 10px; padding: 9px 12px; } +.co-source-error { + display: flex; flex-direction: column; align-items: flex-start; gap: 7px; + max-width: 520px; margin: 60px auto; padding: 18px; +} +.co-source-error strong { color: var(--text); font-size: 15px; } +.co-source-error p { margin: 0; } +.co-source-unavailable { margin-top: 12px; padding: 12px; } + +.co-source-summary { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; + margin: 10px 0; +} +.co-source-summary > div { + min-width: 0; padding: 11px 12px; border: 1px solid var(--border); + border-radius: 11px; background: var(--surface); +} +.co-source-summary strong { + display: block; font-size: 21px; line-height: 1.15; font-weight: 680; + font-variant-numeric: tabular-nums; +} +.co-source-summary span { display: block; margin-top: 2px; color: var(--muted); font-size: 11.5px; } + +.co-source-filters { + display: flex; gap: 6px; padding: 2px 0 12px; overflow-x: auto; + scrollbar-width: none; overscroll-behavior-inline: contain; +} +.co-source-filters::-webkit-scrollbar { display: none; } +.co-source-filter { + flex: 0 0 auto; min-height: 34px; padding: 6px 11px; + border: 1px solid var(--border); border-radius: 999px; + background: transparent; color: var(--muted); font: inherit; + font-size: 12px; font-weight: 600; cursor: pointer; +} +.co-source-filter.is-active { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); +} +.co-source-no-results { + padding: 36px 16px; text-align: center; color: var(--muted); font-size: 13px; +} + +.co-source-layout { + display: grid; grid-template-columns: minmax(310px, 390px) minmax(0, 1fr); + gap: 12px; align-items: start; +} +.co-source-list { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.co-source-row-wrap { + min-width: 0; border: 1px solid var(--border); border-radius: 12px; + background: var(--surface); overflow: hidden; +} +.co-source-row-wrap.is-selected { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); +} +.co-source-row { + display: block; width: 100%; padding: 12px; border: 0; + background: transparent; color: var(--text); font: inherit; text-align: left; + cursor: pointer; +} +@media (hover:hover) { + .co-source-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } +} +.co-source-row-head { display: flex; align-items: center; gap: 9px; min-width: 0; } +.co-source-glyph { + width: 31px; height: 31px; flex: 0 0 auto; border-radius: 9px; + display: inline-flex; align-items: center; justify-content: center; + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); + color: var(--accent); font-size: 13px; font-weight: 750; +} +.co-source-glyph.is-platform { + background: color-mix(in srgb, var(--accent) 17%, var(--surface)); +} +.co-source-glyph svg { width: 18px; height: 18px; } +.co-source-row-id { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.co-source-row-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 13.5px; font-weight: 680; +} +.co-source-row-repo { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-family: var(--mono, var(--font)); font-size: 10.5px; +} +.co-source-status { + flex: 0 0 auto; max-width: 128px; padding: 5px 8px; border-radius: 999px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + background: var(--surface2, var(--bg)); color: var(--muted); + font-size: 10.5px; line-height: 1; font-weight: 680; +} +.co-source-status.tone-accent { background: color-mix(in srgb, var(--accent) 13%, transparent); color: var(--accent); } +.co-source-status.tone-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); } +.co-source-status.tone-warn { + background: color-mix(in srgb, var(--accent) 11%, transparent); color: var(--text); + border: 1px dashed color-mix(in srgb, var(--accent) 38%, var(--border)); +} +.co-source-status.tone-danger { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); } + +.co-source-compact { margin: 10px 0 0 40px; } +.co-source-mini-lane { + display: grid; grid-template-columns: minmax(54px, auto) minmax(54px, 1fr) minmax(50px, auto); + align-items: center; gap: 7px; +} +.co-source-mini-ref { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-size: 9.5px; +} +.co-source-mini-ref.is-local { text-align: right; color: var(--text); } +.co-source-mini-track { position: relative; display: block; height: 12px; } +.co-source-mini-track b { + position: absolute; left: 4px; right: 4px; top: 5px; height: 2px; + background: var(--border); +} +.co-source-mini-track.state-customized b, +.co-source-mini-track.state-diverged b, +.co-source-mini-track.state-working b { background: color-mix(in srgb, var(--accent) 60%, var(--border)); } +.co-source-mini-track.state-incoming b { background: linear-gradient(90deg, var(--accent), var(--border)); } +.co-source-mini-node { + position: absolute; z-index: 1; left: 0; top: 2px; width: 8px; height: 8px; + border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); +} +.co-source-mini-node.is-local { left: auto; right: 0; border-color: var(--accent); } +.co-source-mini-track em { + position: absolute; left: calc(100% + 2px); top: 5px; width: 9px; + border-top: 2px dotted var(--danger); +} +.co-source-compact-meta { + display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; + color: var(--muted); font-size: 10.5px; font-variant-numeric: tabular-nums; +} +.co-source-compact-meta span + span { position: relative; } +.co-source-compact-meta span + span::before { + content: '·'; position: absolute; left: -7px; color: color-mix(in srgb, var(--muted) 65%, transparent); +} +.co-source-compact-meta.is-external { margin-left: 40px; } + +.co-source-desktop-detail { position: sticky; top: 10px; min-width: 0; } +.co-source-mobile-detail { display: none; } +.co-source-detail { + min-width: 0; padding: 15px; border: 1px solid var(--border); + border-radius: 12px; background: var(--surface); +} +.co-source-detail-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; + padding-bottom: 13px; border-bottom: 1px solid var(--border); +} +.co-source-detail-title { display: flex; align-items: center; gap: 10px; min-width: 0; } +.co-source-detail-title > div { min-width: 0; } +.co-source-detail-title h3 { + margin: 0; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; +} +.co-source-detail-title p { + margin: 2px 0 0; color: var(--muted); font-family: var(--mono, var(--font)); + font-size: 10.5px; overflow-wrap: anywhere; +} + +.co-rel { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(62px, .65fr) minmax(0, 1fr); + align-items: center; gap: 10px; padding: 16px 2px 13px; +} +.co-rel-endpoint { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.co-rel-endpoint.is-local { text-align: right; } +.co-rel-kicker { color: var(--muted); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } +.co-rel-endpoint strong { font-size: 12px; line-height: 1.25; } +.co-rel-endpoint > span:last-child { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--muted); font-family: var(--mono, var(--font)); font-size: 9.5px; +} +.co-rel-track { position: relative; height: 20px; } +.co-rel-track b { + position: absolute; left: 7px; right: 7px; top: 9px; height: 2px; background: var(--border); +} +.co-rel-track.state-customized b, +.co-rel-track.state-working b, +.co-rel-track.state-diverged b { background: color-mix(in srgb, var(--accent) 62%, var(--border)); } +.co-rel-track.state-incoming b { background: linear-gradient(90deg, var(--accent), var(--border)); } +.co-rel-track i { + position: absolute; z-index: 1; left: 0; top: 4px; width: 12px; height: 12px; + border: 2px solid var(--muted); border-radius: 50%; background: var(--surface); +} +.co-rel-track i.is-local { left: auto; right: 0; border-color: var(--accent); } +.co-rel-track em { + position: absolute; left: calc(100% + 3px); top: 9px; width: 12px; + border-top: 2px dotted var(--danger); +} +.co-rel-track i.is-working { + left: calc(100% + 14px); right: auto; width: 8px; height: 8px; top: 6px; + border-color: var(--danger); border-style: dashed; +} +.co-rel-metrics { + grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, 1fr); + gap: 6px; margin-top: 3px; +} +.co-rel-metrics span { + min-width: 0; padding: 7px 8px; border-radius: 8px; + background: var(--surface2, var(--bg)); color: var(--muted); + font-size: 10px; text-align: center; white-space: nowrap; +} +.co-rel-metrics strong { color: var(--text); font-variant-numeric: tabular-nums; } + +.co-source-detail-section { padding-top: 13px; margin-top: 1px; border-top: 1px solid var(--border); } +.co-source-detail-section + .co-source-detail-section { margin-top: 13px; } +.co-source-detail-section-head { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + margin-bottom: 8px; color: var(--muted); font-size: 10.5px; +} +.co-source-detail-section-head h4 { margin: 0; color: var(--text); font-size: 12px; } +.co-source-diff-total { display: inline-flex; gap: 8px; font-family: var(--mono, var(--font)); } +.co-source-detail-empty { margin: 0; color: var(--muted); font-size: 11.5px; line-height: 1.5; } +.co-source-file-list { + border: 1px solid var(--border); border-radius: 9px; overflow: hidden; +} +.co-source-file { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; + min-height: 35px; padding: 8px 10px; font-family: var(--mono, var(--font)); font-size: 10.5px; +} +.co-source-file + .co-source-file, +.co-source-files-more { border-top: 1px solid var(--border); } +.co-source-file-path { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.co-source-file-stat { flex: 0 0 auto; display: inline-flex; gap: 7px; color: var(--muted); } +.co-source-file-stat b { color: var(--green); font-style: normal; } +.co-source-file-stat em { color: var(--danger); font-style: normal; } +.co-source-files-more { padding: 8px 10px; color: var(--muted); font-size: 10.5px; text-align: center; } + +.co-source-working-state { color: var(--muted); } +.co-source-working-state.is-dirty { color: var(--danger); } +.co-source-working-counts { display: flex; flex-wrap: wrap; gap: 6px; } +.co-source-working-counts span { + padding: 5px 8px; border-radius: 7px; background: var(--surface2, var(--bg)); + color: var(--muted); font-size: 10.5px; +} +.co-source-working-counts b { color: var(--text); } +.co-source-working-paths { display: flex; flex-direction: column; gap: 6px; margin-top: 9px; } +.co-source-working-paths > div { display: flex; align-items: center; gap: 7px; min-width: 0; } +.co-source-working-paths code { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text); font-family: var(--mono, var(--font)); font-size: 10.5px; +} +.co-source-work-chip { + flex: 0 0 auto; padding: 3px 6px; border-radius: 6px; + color: var(--muted); background: var(--surface2, var(--bg)); font-size: 9px; +} +.co-source-work-chip.is-conflict { color: var(--danger); background: color-mix(in srgb, var(--danger) 11%, transparent); } +.co-source-work-chip.is-untracked { color: var(--accent); background: color-mix(in srgb, var(--accent) 11%, transparent); } + +.co-branch-list { display: flex; flex-direction: column; } +.co-branch { display: grid; grid-template-columns: 22px minmax(0, 1fr); min-width: 0; } +.co-branch-stem { position: relative; min-height: 92px; } +.co-branch-stem::before { + content: ''; position: absolute; left: 8px; top: 0; bottom: 0; + border-left: 2px solid var(--border); +} +.co-branch:last-of-type .co-branch-stem::before { bottom: 50%; } +.co-branch-stem i { + position: absolute; z-index: 1; left: 3px; top: 17px; width: 12px; height: 12px; + border: 2px solid var(--accent); border-radius: 50%; background: var(--surface); +} +.co-branch.needs-attention .co-branch-stem i { border-color: var(--danger); } +.co-branch-body { min-width: 0; padding: 10px 0 12px 3px; } +.co-branch-top { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; } +.co-branch-chip { + padding: 4px 7px; border-radius: 999px; font-size: 9.5px; font-weight: 680; + color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.co-branch-chip.is-danger { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.co-branch-number { color: var(--muted); font-family: var(--mono, var(--font)); font-size: 10px; } +.co-branch-title { + display: block; color: var(--text); font-size: 11.5px; font-weight: 650; + line-height: 1.4; text-decoration: none; overflow-wrap: anywhere; +} +a.co-branch-title:hover { text-decoration: underline; } +.co-branch-name { + display: block; margin-top: 4px; color: var(--muted); font-family: var(--mono, var(--font)); + font-size: 9.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.co-branch-relation, +.co-branch-attention { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; } +.co-branch-attention { color: var(--danger); line-height: 1.4; } +.co-source-review-btn { align-self: flex-start; margin: 6px 0 0 25px; } + +.co-source-loading { + display: flex; align-items: center; justify-content: center; gap: 12px; + min-height: 48dvh; color: var(--muted); +} +.co-source-loading > div { display: flex; flex-direction: column; gap: 3px; } +.co-source-loading strong { color: var(--text); font-size: 14px; } +.co-source-loading span:last-child { font-size: 12px; } +@keyframes co-spin { to { transform: rotate(360deg); } } +.ma-spinner { + width: 24px; height: 24px; flex: 0 0 auto; border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); + border-top-color: var(--accent); animation: co-spin .8s linear infinite; +} +@media (prefers-reduced-motion: reduce) { .ma-spinner { animation: none; } } + /* Stat tiles: label + value wear text tokens, never status colors — the counts are magnitudes, and the group labels below carry the identity. */ .co-tiles { @@ -513,7 +858,39 @@ export const CSS = ` .co-empty-text { margin: 0; font-size: 14px; line-height: 1.6; } /* /mobius-ui:Empty */ +@media (max-width: 760px) { + .co-page.is-sources { width: min(100%, 680px); } + .co-sources-head { flex-direction: column; gap: 9px; } + .co-sources-fresh { width: 100%; justify-content: space-between; } + .co-source-summary { grid-template-columns: repeat(2, 1fr); } + .co-source-layout { display: block; } + .co-source-desktop-detail { display: none; } + .co-source-mobile-detail { display: block; border-top: 1px solid var(--border); } + .co-source-mobile-detail .co-source-detail { border: 0; border-radius: 0; background: transparent; } + .co-source-mobile-detail .co-source-detail-head { display: none; } + .co-source-row-wrap.is-selected { box-shadow: none; } +} + @media (max-width: 520px) { + .co-page { padding-inline: 12px; } + .co-tabs { margin-top: 11px; } + .co-tabs button { padding-inline: 8px; font-size: 12.5px; } + .co-source-summary > div { padding: 10px 11px; } + .co-source-summary strong { font-size: 19px; } + .co-source-row { padding: 11px; } + .co-source-compact { margin-left: 0; } + .co-source-mini-lane { margin-left: 40px; } + .co-source-compact-meta { margin-left: 40px; } + .co-source-detail { padding: 13px 11px 15px; } + .co-source-detail-head { align-items: center; } + .co-source-detail-head .co-source-glyph { display: none; } + .co-rel { + grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr); + gap: 7px; + } + .co-rel-endpoint strong { font-size: 11px; } + .co-rel-metrics { gap: 4px; } + .co-rel-metrics span { padding-inline: 4px; font-size: 9.5px; } .co-attention { flex-direction: column; align-items: stretch; } diff --git a/ui/SourceMap.jsx b/ui/SourceMap.jsx new file mode 100644 index 0000000..f176fb5 --- /dev/null +++ b/ui/SourceMap.jsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useState } from 'react' +import { + attachSourceProjects, + contributionRelationship, + formatSourceDelta, + projectMatchesFilter, + projectStatus, + recordBranch, + sourceSummary, +} from '../source-map.js' + +const FILTERS = [ + ['all', 'All'], + ['attention', 'Attention'], + ['different', 'Different'], + ['working', 'Working'], + ['prs', 'Has PRs'], + ['aligned', 'Aligned'], +] + +function shortSha(value) { + return value ? String(value).slice(0, 7) : '—' +} + +function countLabel(value, singular, plural = singular + 's') { + return value + ' ' + (value === 1 ? singular : plural) +} + +function ProjectGlyph({ project }) { + if (project.kind === 'platform') { + return ( + + ) + } + const letter = String(project.name || '?').trim().charAt(0).toUpperCase() || '?' + return +} + +function CompactLane({ project }) { + const base = project.kind === 'platform' + ? 'origin/main' + : !project.has_update_source ? 'No update source' + : project.version ? 'Installed ' + project.version : (project.base_ref || 'Update source') + const incoming = Number(project.behind || 0) + const treeFiles = Number(project.tree?.files || 0) + const working = Number(project.workingFiles || 0) + const counts = project.contributionCounts || { ready: 0, open: 0 } + const aria = [ + project.name, + incoming ? countLabel(incoming, 'incoming commit') : 'no incoming commits', + !project.has_update_source ? 'no recorded update source' + : treeFiles ? countLabel(treeFiles, 'different file') : 'source trees match', + working ? countLabel(working, 'working file') : 'no working files', + counts.ready ? countLabel(counts.ready, 'ready contribution') : '', + counts.open ? countLabel(counts.open, 'open contribution') : '', + ].filter(Boolean).join(', ') + return ( +
+ +
+ {!project.has_update_source + ? 'Local Git only' + : treeFiles ? treeFiles + (treeFiles === 1 ? ' file differs' : ' files differ') : 'Trees match'} + {incoming > 0 && {incoming} incoming} + {working > 0 && {working} working} + {counts.ready > 0 && {counts.ready} ready} + {counts.open > 0 && {counts.open} open} +
+
+ ) +} + +function ProjectRow({ project, selected, onSelect, mobileDetail, onShowContributions }) { + const status = projectStatus(project) + return ( +
+ + {selected && mobileDetail && ( +
+ +
+ )} +
+ ) +} + +function RelationshipRail({ project }) { + const working = project.working || {} + const baseTitle = project.kind === 'platform' ? 'Last-fetched origin' : 'Installed upstream' + const baseSubtitle = project.base_ref || 'No update source' + const localTitle = 'Your main' + const localSubtitle = project.branch || (project.detached ? 'Detached HEAD' : 'Unknown branch') + return ( +
+
+ Update source + {baseTitle} + {baseSubtitle} · {shortSha(project.base_sha)} +
+ +
+ Live source + {localTitle} + {localSubtitle} · {shortSha(project.head_sha)} +
+
+ {project.behind ?? '—'} incoming commits + {project.ahead ?? '—'} local commits + {project.tree?.files ?? '—'} different files +
+
+ ) +} + +function ChangedSource({ project }) { + const tree = project.tree + if (!tree?.available) return null + return ( +
+
+

Changed source

+ + +{tree.insertions} + −{tree.deletions} + +
+ {tree.files === 0 ? ( +

The recorded source and your main have the same tree.

+ ) : ( +
+ {(tree.paths || []).map((file) => ( +
+ {file.path} + + {file.binary ? 'binary' : <>+{file.insertions}−{file.deletions}} + +
+ ))} + {tree.truncated && ( +
+{tree.files - tree.paths.length} more files
+ )} +
+ )} +
+ ) +} + +function WorkingSource({ project }) { + const working = project.working + if (!working?.available) return null + const parts = [ + ['staged', working.staged], + ['unstaged', working.unstaged], + ['untracked', working.untracked], + ['conflicted', working.conflicts], + ].filter(([, count]) => count > 0) + return ( +
+
+

Working files

+ + {working.files ? countLabel(working.files, 'file') : 'No working changes'} + +
+ {parts.length === 0 ? ( +

Nothing staged, unstaged, or untracked.

+ ) : ( + <> +
+ {parts.map(([label, count]) => {count} {label})} +
+
+ {(working.paths || []).map((item) => ( +
+ {item.group} + {item.path} +
+ ))} + {working.truncated && More working paths are not shown.} +
+ + )} +
+ ) +} + +function ContributionLane({ project, onShowContributions }) { + const contributions = project.contributions || [] + return ( +
+
+

Contribution branches

+ {contributions.length > 0 && {contributions.length} active} +
+ {contributions.length === 0 ? ( +

No ready or open contributions for this source.

+ ) : ( +
+ {contributions.map((rec) => { + const ready = rec.status === 'prepared' + const label = ready ? 'Ready' : rec.status === 'submitting' ? 'Submitting' : 'Open' + const title = rec.title || rec.summary || recordBranch(rec) || 'Untitled contribution' + return ( +
+ +
+
+ + {rec.needs_attention ? (rec.attention?.title || 'Attention') : label} + + {rec.number && #{rec.number}} +
+ {rec.url ? ( + {title} + ) : {title}} + {recordBranch(rec) || 'branch unavailable'} + {contributionRelationship(rec, project)} + {rec.needs_attention && rec.attention?.message && ( + {rec.attention.message} + )} +
+
+ ) + })} + +
+ )} +
+ ) +} + +function ProjectDetail({ project, onShowContributions }) { + const status = projectStatus(project) + return ( +
+
+
+ +
+

{project.name}

+

{project.canonical_repo || 'This source lives only on this Möbius.'}

+
+
+ {status.label} +
+ {project.kind !== 'external' && project.available ? ( + <> + + + ) : project.kind !== 'external' ? ( +
+ This repository could not be inspected. Contributions remain visible below. +
+ ) : null} + + {project.kind !== 'external' && project.available && ( + <> + + + + )} +
+ ) +} + +function LoadingState() { + return ( +
+
+ ) +} + +export function SourceMap({ snapshot, records, conn, loading, error, onRetry, onShowContributions }) { + const [filter, setFilter] = useState('all') + const projects = useMemo(() => attachSourceProjects(snapshot, records), [snapshot, records]) + const summary = useMemo(() => sourceSummary(projects), [projects]) + const filtered = useMemo( + () => projects.filter((project) => projectMatchesFilter(project, filter)), + [projects, filter], + ) + const [selected, setSelected] = useState('platform') + useEffect(() => { + if (filtered.length > 0 && !filtered.some((project) => project.key === selected)) { + setSelected(filtered[0].key) + } + }, [filtered, selected]) + const selectedProject = filtered.find((project) => project.key === selected) || filtered[0] + + if (loading && !snapshot) return + if (error && !snapshot) { + return ( +
+ {error === 'restart' ? 'Restart to finish Sources' : 'Source map unavailable'} +

{error === 'restart' + ? 'The Sources view is installed, but its new read-only platform service starts after the next Möbius restart.' + : 'Contribute could not read local source status. Your contribution feed is unaffected.'}

+ +
+ ) + } + + const compared = snapshot?.generated_at + ? new Date(snapshot.generated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : '' + return ( +
+
+
+

Source map

+

Recorded update sources, your live mains, working files, and active contribution branches.

+
+
+ {compared && Compared {compared}} + +
+
+ + {conn?.state !== 'connected' && ( +
+ Local comparisons are available; contribution states may be stale until GitHub reconnects. +
+ )} + +
+
{summary.sources}Sources
+
{summary.different}Different
+
{summary.working}Working files
+
{summary.active}Active shares
+
+ +
+ {FILTERS.map(([key, label]) => ( + + ))} +
+ + {filtered.length === 0 ? ( +
No sources match this filter.
+ ) : ( +
+
+ {filtered.map((project) => ( + + ))} +
+ {selectedProject && ( + + )} +
+ )} +
+ ) +}