diff --git a/GitWeb/electron.vite.config.ts b/GitWeb/electron.vite.config.ts index 06ebc9a..e578672 100644 --- a/GitWeb/electron.vite.config.ts +++ b/GitWeb/electron.vite.config.ts @@ -1,8 +1,18 @@ import { resolve } from 'path' +import { execFileSync } from 'child_process' import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +/** The commit this build came from, for About's "git log -1" card; '' outside a checkout. */ +function buildCommit(): string { + try { + return execFileSync('git', ['rev-parse', '--short=7', 'HEAD'], { cwd: __dirname, encoding: 'utf8' }).trim() + } catch { + return '' + } +} + export default defineConfig({ main: { build: { @@ -30,7 +40,8 @@ export default defineConfig({ } }, define: { - __BUILD_TIME__: JSON.stringify(new Date().toISOString()) + __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + __BUILD_COMMIT__: JSON.stringify(buildCommit()) }, plugins: [react(), tailwindcss()] } diff --git a/GitWeb/src/main/gitProcess.ts b/GitWeb/src/main/gitProcess.ts index 507a408..b688cdc 100644 --- a/GitWeb/src/main/gitProcess.ts +++ b/GitWeb/src/main/gitProcess.ts @@ -1064,13 +1064,26 @@ async function compareWithDefault( return { vsHead, hasLocalDefault, vsLocalDefault: hasLocalDefault ? vsLocalDefaultRaw : null } } +/** HEAD's subject and committer date, or null on an unborn branch. */ +async function readLastCommit(cwd: string): Promise<{ subject: string; date: string } | null> { + const result = await readGit(['log', '-1', '--format=%cI%x00%s', 'HEAD', '--'], cwd) + if (result.exitCode !== 0) return null + const [date, subject] = result.stdout.trim().split('\0') + return date ? { date, subject: subject ?? '' } : null +} + export async function getRepoUpdateStatus( cwd: string, options?: { fetch?: boolean } ): Promise { // Both are read-only and skip the repo queue, so they can overlap. - const [status, remoteUrl] = await Promise.all([getStatus(cwd), getRemoteUrlForPolling(cwd)]) + const [status, remoteUrl, lastCommit] = await Promise.all([ + getStatus(cwd), + getRemoteUrlForPolling(cwd), + readLastCommit(cwd) + ]) const common = { + lastCommit, branch: status.branch, defaultBranch: null as string | null, behindDefault: 0, diff --git a/GitWeb/src/renderer/src/browserApiStub.ts b/GitWeb/src/renderer/src/browserApiStub.ts index b7a738a..a87d117 100644 --- a/GitWeb/src/renderer/src/browserApiStub.ts +++ b/GitWeb/src/renderer/src/browserApiStub.ts @@ -242,7 +242,14 @@ const recent: RecentRepository[] = [ // One recent repository per state the start screen can draw. function demoRepoStatus(path: string): RepoUpdateStatus { - const base = { ...demoUpdateStatus, localChanges: 0, ahead: 0, behind: 0, refreshing: false } + const base = { + ...demoUpdateStatus, + localChanges: 0, + ahead: 0, + behind: 0, + refreshing: false, + lastCommit: { subject: 'Ajusta o espaçamento do cabeçalho', date: new Date(Date.now() - 3 * 3600_000).toISOString() } + } if (path.endsWith('api-pagamentos')) { return { ...base, state: 'diverged', branch: 'develop', defaultBranch: 'main', localDefaultAhead: 2, localDefaultBehind: 5, behindDefault: 5, canFastForward: false, message: 'main local divergiu de origin/main.' } } diff --git a/GitWeb/src/renderer/src/components/AboutGraph.tsx b/GitWeb/src/renderer/src/components/AboutGraph.tsx new file mode 100644 index 0000000..38253fe --- /dev/null +++ b/GitWeb/src/renderer/src/components/AboutGraph.tsx @@ -0,0 +1,209 @@ +import { useId } from 'react' +import type { UpdateStatus } from '../../../shared/updates' +import { LaneMark } from './RepoLane' + +/** + * About's hero: OpenGitDesktop drawn as one of the repositories it manages. + * History runs in from the left — commits fading with distance, two branches + * that forked and merged back — into HEAD, which is the app's own mark: the + * installed version. To the right is origin/main, and it tells the update + * state in the lanes' own vocabulary: a release on the remote forks off in + * Attention Orange (commits waiting on the remote, as on the start screen), + * the download pulls it in by filling that branch in Command Blue, and a + * finished download turns the tip Success Green — ready to fast-forward. + * + * The words live in the update panel below; this drawing is its twin, so it + * carries one summary label for screen readers and nothing else. + */ + +const VW = 1400 +const VH = 230 +const HX = 700 +const HY = 115 +const EDGE = 40 // half the emblem tile plus its ring, where lanes stop short +const BRANCH = 54 + +// Trunk commits, nearest to HEAD first, one step apart. +const HISTORY = [630, 566, 502, 438, 374, 310, 246, 182, 118] + +/** Nearer HEAD reads stronger; the far past fades into the window edge. */ +function fade(x: number): number { + return 0.15 + 0.75 * (x / HISTORY[0]) +} + +function mergedBranch(from: number, to: number, y: number): string { + return `M ${from} ${HY} C ${from + 24} ${HY}, ${from + 24} ${y}, ${from + 48} ${y} L ${to - 48} ${y} C ${to - 24} ${y}, ${to - 24} ${HY}, ${to} ${HY}` +} + +function Node({ x, y, color, opacity, delay, r = 4 }: { x: number; y: number; color: string; opacity: number; delay: number; r?: number }): JSX.Element { + return ( + + ) +} + +function AboutGraph({ + state, + percent, + targetVersion, + label +}: { + state: UpdateStatus['state'] + percent: number + targetVersion: string | null + label: string +}): JSX.Element { + const id = useId().replace(/:/g, '') + const past = `about-past-${id}` + const future = `about-future-${id}` + + const inFlow = state === 'available' || state === 'downloading' || state === 'downloaded' + const ready = state === 'downloaded' + const tipX = HX + 310 + const tipY = HY - BRANCH + const forkPath = `M ${HX + EDGE} ${HY} C ${HX + 76} ${HY}, ${HX + 76} ${tipY}, ${HX + 112} ${tipY} L ${tipX - 10} ${tipY}` + + return ( +
+ + + + + + + + + + + + + {/* History: the trunk and two branches that came home. */} + + + + + + + {HISTORY.map((x, i) => ( + + ))} + + {/* The remote side, one shape per update state. */} + {inFlow ? ( + <> + {/* Local main stays where it is until the pull lands. */} + + + + + + origin/main + + + {targetVersion ? `v${targetVersion}` : '…'} + + + ) : state === 'not-available' ? ( + <> + {/* HEAD and origin/main are the same commit: one line, no fork. */} + + + origin/main + + + ) : ( + <> + {/* Not known yet (or not reachable): the remote is only a promise. */} + + + origin/main + + + )} + + + {/* HEAD: the app's own mark, sitting on its trunk. */} + {/* Positioned by the wrapper, animated by the tile: rise-in owns the + tile's transform, so it must not also carry the centring translate. */} + + + + + +
+ ) +} + +export default AboutGraph diff --git a/GitWeb/src/renderer/src/components/AiSettingsForm.tsx b/GitWeb/src/renderer/src/components/AiSettingsForm.tsx index f2c7df6..cad4faf 100644 --- a/GitWeb/src/renderer/src/components/AiSettingsForm.tsx +++ b/GitWeb/src/renderer/src/components/AiSettingsForm.tsx @@ -442,16 +442,6 @@ function AiSettingsForm(): JSX.Element | null { )} -
- - -
- {testResult &&

{testResult}

} -
@@ -481,6 +471,17 @@ function AiSettingsForm(): JSX.Element | null {
+ + {/* Last, under everything they act on: Save keeps the whole form. */} +
+ + +
+ {testResult &&

{testResult}

} ) } diff --git a/GitWeb/src/renderer/src/components/BenchPulse.tsx b/GitWeb/src/renderer/src/components/BenchPulse.tsx new file mode 100644 index 0000000..68e6d5f --- /dev/null +++ b/GitWeb/src/renderer/src/components/BenchPulse.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import type { Locale } from '../../../shared/i18n' +import { relativeTime } from '../lib/relativeTime' + +/** + * The signed-in user as HEAD: a branch leaves the bottom of their avatar and + * runs right, carrying one node per repository at the age of its last commit — + * near the avatar is now, the far end is WINDOW_DAYS ago (older ones rest + * there, faded). The same gesture as the app's mark and the row lanes, so the + * footer reads as part of the bench rather than as a profile widget. + * + * Monochrome on purpose: hue on this screen means repository state, and age is + * not a state. Recency is carried by opacity and the newest node's size. + */ + +const W = 292 +const H = 30 +const TRUNK = 18 // avatar centre, in the footer's own coordinates +const LANE_Y = 20 +const X0 = 44 +const X1 = W - 58 +const DAY = 86_400_000 +export const WINDOW_DAYS = 60 + +export interface PulsePoint { + name: string + date: string +} + +function ageRatio(iso: string, now: number): number { + const days = Math.max(0, (now - Date.parse(iso)) / DAY) + // sqrt spreads the recent end, where most of the interest is. + return Math.min(1, Math.sqrt(days / WINDOW_DAYS)) +} + +function BenchPulse({ + points, + locale, + windowLabel, + title +}: { + points: PulsePoint[] + locale: Locale + windowLabel: string + title: string +}): JSX.Element | null { + const [now] = useState(Date.now) + const placed = points + .filter((p) => Number.isFinite(Date.parse(p.date))) + .map((p) => ({ ...p, t: ageRatio(p.date, now) })) + // Oldest first, so the newest nodes paint on top where they overlap. + .sort((a, b) => b.t - a.t) + if (placed.length === 0) return null + const newest = placed[placed.length - 1] + + return ( + + {title} + {/* Out of the avatar, bend, and away — the branch gesture. */} + + {/* Past the window: the lane fades out instead of ending on a cap. */} + + {placed.map((p, i) => { + const isNewest = p === newest + return ( + = 1 ? 2 : 2.75} + fill="var(--color-primary-text)" + fillOpacity={isNewest ? 1 : 0.85 - 0.6 * p.t} + stroke="var(--color-obsidian-panel)" + strokeWidth={1.5} + className="lane-node" + style={{ animationDelay: `${240 + p.t * 360}ms` }} + > + {`${p.name} · ${relativeTime(locale, p.date, now)}`} + + ) + })} + + {windowLabel} + + + ) +} + +export default BenchPulse diff --git a/GitWeb/src/renderer/src/components/CloneModal.tsx b/GitWeb/src/renderer/src/components/CloneModal.tsx index 4d2f123..7228a8f 100644 --- a/GitWeb/src/renderer/src/components/CloneModal.tsx +++ b/GitWeb/src/renderer/src/components/CloneModal.tsx @@ -25,6 +25,7 @@ import CloneDestinationField from './CloneDestinationField' import { useCloneProgress } from '../lib/useCloneProgress' import { useFormatters, useLocale, useT } from '../lib/i18n' import { relativeTime } from '../lib/relativeTime' +import SegmentThumb from './SegmentThumb' type CloneTab = 'github' | 'url' | 'token' @@ -650,6 +651,7 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null {
+ {tabs.map((tab) => ( @@ -138,7 +142,7 @@ function RepoGroupSection({ )} {id !== null && !editing && ( - + )} +
{!collapsed && diff --git a/GitWeb/src/renderer/src/components/SegmentThumb.tsx b/GitWeb/src/renderer/src/components/SegmentThumb.tsx new file mode 100644 index 0000000..8c59612 --- /dev/null +++ b/GitWeb/src/renderer/src/components/SegmentThumb.tsx @@ -0,0 +1,81 @@ +import { useLayoutEffect, useRef } from 'react' + +/** + * The raised pill behind the active option of a `.clone-segmented` control, + * sliding from the old option to the new one instead of the background just + * blinking across. Drop it in as the control's first child. + * + * It finds the active option by its ARIA state (`aria-selected` on tabs, + * `aria-checked` on radios) and writes position and width straight onto its + * own element: no React state, so a click costs one render, and a resize of + * the control (the PR list is draggable) only moves the pill. + * + * It leaves on press, not on release: a click only fires on mouseup, and + * waiting for it made every switch read as "click, then slide". If the press + * ends anywhere but on that option, the pill goes back to the real selection. + */ +function SegmentThumb({ activeKey }: { activeKey: string }): JSX.Element { + const ref = useRef(null) + + useLayoutEffect(() => { + const thumb = ref.current + const control = thumb?.parentElement + if (!thumb || !control) return + // Which option it was last sent to; also what makes a new choice re-place it. + thumb.dataset.option = activeKey + + function moveTo(option: HTMLElement | null): void { + if (!thumb) return + if (!option) { + thumb.style.opacity = '0' + return + } + thumb.style.opacity = '1' + thumb.style.width = `${option.offsetWidth}px` + thumb.style.transform = `translateX(${option.offsetLeft}px)` + } + + function selected(): HTMLElement | null { + return control?.querySelector('[aria-selected="true"], [aria-checked="true"]') ?? null + } + + function place(): void { + moveTo(selected()) + } + + function onPointerDown(e: PointerEvent): void { + if (e.button !== 0) return + const option = (e.target as HTMLElement).closest('.clone-segment') + if (option && !option.matches(':disabled') && control?.contains(option)) moveTo(option) + } + + // A press released elsewhere (or cancelled) never clicks: go back home. + // A real click re-renders first, and its layout effect places the pill + // on the new selection before this frame runs. + function onPointerEnd(): void { + requestAnimationFrame(place) + } + + place() + // First placement lands without motion; only later moves slide. + const frame = requestAnimationFrame(() => { + thumb.dataset.ready = 'true' + }) + const observer = new ResizeObserver(place) + observer.observe(control) + control.addEventListener('pointerdown', onPointerDown) + window.addEventListener('pointerup', onPointerEnd) + window.addEventListener('pointercancel', onPointerEnd) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + control.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('pointerup', onPointerEnd) + window.removeEventListener('pointercancel', onPointerEnd) + } + }, [activeKey]) + + return +} + +export default SegmentThumb diff --git a/GitWeb/src/renderer/src/components/ThemeToggle.tsx b/GitWeb/src/renderer/src/components/ThemeToggle.tsx deleted file mode 100644 index 5bf8bc2..0000000 --- a/GitWeb/src/renderer/src/components/ThemeToggle.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState } from 'react' -import { Moon, Sun } from 'lucide-react' -import { useAppSettingsStore } from '../store/appSettingsStore' -import { useT } from '../lib/i18n' - -function useSystemDark(): boolean { - const [dark, setDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) - useEffect(() => { - const query = window.matchMedia('(prefers-color-scheme: dark)') - const sync = (): void => setDark(query.matches) - query.addEventListener('change', sync) - return () => query.removeEventListener('change', sync) - }, []) - return dark -} - -/** - * One click between light and dark. It writes an explicit preference (the - * Settings view still offers "follow the system"), so the choice survives a - * restart. The icon shows where the click takes you: a sun in the dark, a - * moon in the light — and the two swap with a quarter turn. - */ -function ThemeToggle({ className = '' }: { className?: string }): JSX.Element { - const preference = useAppSettingsStore((s) => s.settings?.colorScheme ?? 'system') - const updateSettings = useAppSettingsStore((s) => s.updateSettings) - const systemDark = useSystemDark() - const t = useT().topBar - const dark = preference === 'system' ? systemDark : preference === 'dark' - const label = dark ? t.themeToLight : t.themeToDark - - return ( - - ) -} - -export default ThemeToggle diff --git a/GitWeb/src/renderer/src/components/TopBar.tsx b/GitWeb/src/renderer/src/components/TopBar.tsx index d4a6abe..4e0f759 100644 --- a/GitWeb/src/renderer/src/components/TopBar.tsx +++ b/GitWeb/src/renderer/src/components/TopBar.tsx @@ -38,7 +38,6 @@ import Github from './icons/Github' import { openSettings } from '../lib/openAiSettings' import { useT } from '../lib/i18n' import type { Messages } from '../../../shared/i18n' -import ThemeToggle from './ThemeToggle' import PublishRepositoryDialog from './PublishRepositoryDialog' function timeAgo(t: Messages['topBar'], timestamp: number | null): string { @@ -788,7 +787,6 @@ function TopBar(): JSX.Element {
- diff --git a/GitWeb/src/renderer/src/env.d.ts b/GitWeb/src/renderer/src/env.d.ts index 75d5bb8..cc4fd45 100644 --- a/GitWeb/src/renderer/src/env.d.ts +++ b/GitWeb/src/renderer/src/env.d.ts @@ -1,3 +1,5 @@ /// declare const __BUILD_TIME__: string +/** Short hash of the commit the build came from; '' when built outside a git checkout. */ +declare const __BUILD_COMMIT__: string diff --git a/GitWeb/src/renderer/src/styles/global.css b/GitWeb/src/renderer/src/styles/global.css index fcde006..8ed4f63 100644 --- a/GitWeb/src/renderer/src/styles/global.css +++ b/GitWeb/src/renderer/src/styles/global.css @@ -360,6 +360,16 @@ — the investigation's active filename, and the chat's own busy labels. The surrounding row stays stable, so the motion signals work without making the whole progress line visually noisy. */ +@keyframes agent-draft-ripple { + 0%, + 100% { + transform: scaleY(1); + } + 45% { + transform: scaleY(0.35); + } +} + @keyframes agent-busy-shimmer { 0% { background-position: 160% 0; @@ -537,6 +547,30 @@ } } +/* New PR wizard: the step that just became current sends one ring out. */ +@keyframes wizard-node-ping { + 0% { + box-shadow: + 0 0 0 4px var(--color-command-blue-soft), + 0 0 0 0 color-mix(in oklab, var(--color-command-blue) 45%, transparent); + } + 100% { + box-shadow: + 0 0 0 4px var(--color-command-blue-soft), + 0 0 0 14px transparent; + } +} +@keyframes wizard-card-in { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + 100% { + opacity: 1; + transform: none; + } +} + @keyframes wizard-field-flash { 0% { box-shadow: 0 0 0 3px var(--color-command-blue-soft); @@ -977,6 +1011,59 @@ and the sparkle turns, so the AI entry point feels alive without competing with the primary action. While generating, the label uses the agent shimmer instead. */ + /* "Redigir pelo diff": the PR draft button. Its mark is a little diff stat + (green adds, red deletes) in a recessed well — what the agent reads — + instead of the generic AI sparkle. Hover ripples the bars once around; + while the agent reads, they keep rippling. */ + .agent-draft { + @apply inline-flex h-8 items-center gap-2 rounded-button border border-default-border bg-coal-raised pl-1 pr-3 text-caption font-medium text-primary-text active:scale-[0.97]; + box-shadow: var(--shadow-raised); + transition: + border-color 180ms ease-out, + background-color 180ms ease-out, + transform 150ms var(--ease-out-expo); + } + .agent-draft:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--color-command-blue) 45%, transparent); + background-color: color-mix(in oklab, var(--color-command-blue) 8%, var(--color-coal-raised)); + } + .agent-draft:disabled { + cursor: progress; + } + .agent-draft-glyph { + @apply flex h-6 w-9 items-end justify-center gap-[2px] rounded-md border border-default-border bg-midnight-base px-1.5 pb-[5px]; + } + .agent-draft-glyph > svg { + align-self: center; + } + .agent-draft-glyph > i { + width: 3px; + border-radius: 1px; + transform-origin: bottom; + } + .agent-draft-glyph > i.is-add { + background: var(--color-diff-add-ink); + } + .agent-draft-glyph > i.is-del { + background: var(--color-danger-red); + } + .agent-draft:hover:not(:disabled) .agent-draft-glyph > i { + animation: agent-draft-ripple 560ms var(--ease-out-expo) 1 backwards; + } + .agent-draft.is-reading .agent-draft-glyph { + border-color: color-mix(in oklab, var(--color-command-blue) 45%, transparent); + } + .agent-draft.is-reading .agent-draft-glyph > i { + animation: agent-draft-ripple 900ms ease-in-out infinite; + } + @media (prefers-reduced-motion: reduce) { + .agent-draft-glyph > i, + .agent-draft:hover:not(:disabled) .agent-draft-glyph > i, + .agent-draft.is-reading .agent-draft-glyph > i { + animation: none; + } + } + .btn-agent { @apply relative inline-flex h-7 items-center gap-1.5 overflow-hidden rounded-button border border-command-blue/30 bg-command-blue-soft px-3 text-caption font-medium text-command-blue active:scale-[0.97]; box-shadow: var(--shadow-raised); @@ -1103,6 +1190,51 @@ /* Empty-state medallion: one icon on a raised tile — used for every "nothing here yet" state so they share a single voice. */ + .wizard-node-active { + animation: wizard-node-ping 900ms var(--ease-out-expo) 1; + } + /* The rail's leading edge glows while it grows, then rests. */ + .wizard-rail-fill { + box-shadow: 0 0 8px color-mix(in oklab, var(--color-command-blue) 60%, transparent); + } + .wizard-card { + animation: wizard-card-in 360ms var(--ease-out-expo) backwards; + transition: + background-color 180ms ease-out, + border-color 180ms ease-out, + box-shadow 220ms var(--ease-out-expo), + transform 220ms var(--ease-out-expo); + } + .wizard-card:not([data-selected='true']):not([data-disabled='true']):hover { + transform: translateY(-2px); + box-shadow: var(--shadow-raised); + } + .wizard-card[data-selected='true'] { + box-shadow: + 0 0 0 3px var(--color-command-blue-soft), + var(--shadow-raised); + } + .wizard-card .empty-medallion { + transition: + color 180ms ease-out, + border-color 180ms ease-out, + background-color 180ms ease-out; + } + .wizard-card[data-selected='true'] .empty-medallion { + color: var(--color-command-blue); + border-color: color-mix(in oklab, var(--color-command-blue) 40%, transparent); + background-color: var(--color-command-blue-soft); + } + @media (prefers-reduced-motion: reduce) { + .wizard-node-active, + .wizard-card { + animation: none; + } + .wizard-card:hover { + transform: none; + } + } + .empty-medallion { @apply flex size-11 items-center justify-center rounded-md border border-default-border bg-coal-raised text-secondary-text; box-shadow: var(--shadow-raised); @@ -1396,6 +1528,47 @@ background-color: var(--color-obsidian-panel); border-color: var(--color-default-border); } + /* Same leading pill as .list-row, so the start screen's rows answer the + pointer in the vocabulary every other list in the app already uses. */ + .workbench-row::before { + content: ''; + position: absolute; + left: -1px; + top: 50%; + width: 3px; + height: 46%; + max-height: 24px; + border-radius: 99px; + background-color: var(--color-primary-text); + transform: translateY(-50%) scaleY(0); + opacity: 0; + transition: + transform 220ms var(--ease-out-expo), + opacity 150ms ease-out; + pointer-events: none; + } + .workbench-row:hover::before, + .workbench-row:focus-within::before { + opacity: 0.22; + transform: translateY(-50%) scaleY(1); + } + /* Hover actions float over the row's tail instead of reserving an empty + column for themselves; the fade lets the text under them recede. */ + .workbench-row-actions { + background: linear-gradient(to right, transparent, var(--color-obsidian-panel) 28px); + } + /* First paint: the list settles in a short cascade, once per mount. */ + .workbench-row-enter { + animation: rise-in 360ms var(--ease-out-expo) backwards; + } + @media (prefers-reduced-motion: reduce) { + .workbench-row-enter { + animation: none; + } + .workbench-row::before { + transition: none; + } + } [data-theme='light'] .workbench-row:hover, [data-theme='light'] .workbench-row:focus-within, [data-theme='light-hc'] .workbench-row:hover, @@ -1447,6 +1620,8 @@ } .clone-segmented { + position: relative; + isolation: isolate; display: inline-flex; gap: 2px; padding: 3px; @@ -1455,6 +1630,8 @@ background: var(--color-midnight-base); } .clone-segment { + position: relative; + z-index: 1; display: inline-flex; align-items: center; gap: 6px; @@ -1469,17 +1646,47 @@ color 150ms var(--ease-out-expo), box-shadow 150ms var(--ease-out-expo); } + /* Hover only lifts the label: a hover surface under the pointer read as a + second selection arriving before the sliding thumb did. */ .clone-segment:hover { color: var(--color-primary-text); - background: var(--color-hover-overlay); } - .clone-segment[aria-selected='true'] { + /* The active option's surface is the sliding thumb below, so the option + itself only brightens its label. */ + .clone-segment[aria-selected='true'], + .clone-segment[aria-checked='true'], + .clone-segment[aria-selected='true']:hover, + .clone-segment[aria-checked='true']:hover { color: var(--color-primary-text); + background: transparent; + } + .clone-segment[aria-selected='true'] svg, + .clone-segment[aria-checked='true'] svg { + color: var(--color-command-blue); + } + .clone-segment-thumb { + position: absolute; + top: 3px; + bottom: 3px; + left: 0; + z-index: 0; + width: 0; + border-radius: 6px; background: var(--color-obsidian-panel); box-shadow: var(--shadow-raised); + opacity: 0; + pointer-events: none; } - .clone-segment[aria-selected='true'] svg { - color: var(--color-command-blue); + .clone-segment-thumb[data-ready='true'] { + transition: + transform 280ms cubic-bezier(0.32, 0.72, 0, 1), + width 280ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 150ms ease-out; + } + @media (prefers-reduced-motion: reduce) { + .clone-segment-thumb[data-ready='true'] { + transition: none; + } } .clone-monogram { @@ -2151,6 +2358,40 @@ /* ── Settings: grouped rows, choice tiles, theme miniatures ──────────────── */ @layer components { + /* Collaborators: rows settle in, warm under the pointer, and keep their + destructive action out of sight until the row is pointed at or focused. */ + .collab-row { + animation: rise-in 360ms var(--ease-out-expo) backwards; + transition: background-color 150ms ease-out; + } + .collab-row:hover, + .collab-row:focus-within { + background-color: var(--color-hover-overlay); + } + /* The people list keeps overflow visible (for the role menus), so the + hover fill rounds its own outer corners to stay inside the card. */ + .collab-row:first-child { + border-top-left-radius: calc(var(--radius-lg) - 1px); + border-top-right-radius: calc(var(--radius-lg) - 1px); + } + .collab-row:last-child { + border-bottom-left-radius: calc(var(--radius-lg) - 1px); + border-bottom-right-radius: calc(var(--radius-lg) - 1px); + } + .collab-row .collab-action { + opacity: 0; + transition: opacity 150ms ease-out; + } + .collab-row:hover .collab-action, + .collab-row:focus-within .collab-action { + opacity: 1; + } + @media (prefers-reduced-motion: reduce) { + .collab-row { + animation: none; + } + } + .settings-heading { display: flex; align-items: center; diff --git a/GitWeb/src/renderer/src/views/AboutView.tsx b/GitWeb/src/renderer/src/views/AboutView.tsx index e6466e9..5d10820 100644 --- a/GitWeb/src/renderer/src/views/AboutView.tsx +++ b/GitWeb/src/renderer/src/views/AboutView.tsx @@ -1,13 +1,17 @@ import { useEffect, useState, type CSSProperties } from 'react' -import { AlertCircle, ArrowLeft, CheckCircle2, Download, FolderGit2, Loader2, RefreshCw, RotateCcw } from 'lucide-react' +import { AlertCircle, ArrowLeft, Bug, CheckCircle2, Download, Loader2, RefreshCw, RotateCcw, Scale, Tag } from 'lucide-react' import { useRepoStore } from '../store/repoStore' import Markdown from '../components/Markdown' +import AboutGraph from '../components/AboutGraph' +import Github from '../components/icons/Github' import type { UpdateStatus } from '../../../shared/updates' import { allTranslations, type Locale, type Messages } from '../../../shared/i18n' import { useLocale, useT } from '../lib/i18n' type AboutMessages = Messages['about'] +const REPO_URL = 'https://github.com/CariSoftwareBR/OpenGitDesktop' + function formatBuildTime(locale: Locale, iso: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return iso @@ -162,9 +166,20 @@ function AboutView(): JSX.Element { : 0 const notes = inFlow ? (target?.notes ?? null) : null + // What each update action would be, said in git — a footnote, never the label. + const gitHint = state === 'downloaded' ? 'git merge --ff-only' : downloading ? 'git pull' : 'git fetch' + const remoteRef = + state === 'downloaded' + ? { text: `origin/main · v${target?.version ?? ''}`, tone: 'border-success-green/25 bg-success-green-soft text-success-green' } + : downloading + ? { text: `origin/main · v${target?.version ?? '…'}`, tone: 'border-attention-orange/25 bg-attention-orange-soft text-attention-orange' } + : state === 'not-available' + ? { text: 'origin/main', tone: 'border-default-border bg-coal-raised text-secondary-text' } + : null + return (
-
+
diff --git a/GitWeb/src/renderer/src/views/CollaboratorsView.tsx b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx index ba73592..aeba322 100644 --- a/GitWeb/src/renderer/src/views/CollaboratorsView.tsx +++ b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx @@ -1,5 +1,26 @@ import { useEffect, useId, useState, type FormEvent, type ReactNode } from 'react' -import { AlertCircle, AlertTriangle, Clock, Loader2, Lock, RefreshCw, UserMinus, UserPlus, Users, X } from 'lucide-react' +import { + AlertCircle, + AlertTriangle, + Building2, + Clock, + ExternalLink, + Eye, + Loader2, + Lock, + PenLine, + RefreshCw, + ShieldCheck, + Tags, + UserCheck, + UserMinus, + UserPlus, + Users, + Wrench, + X, + type LucideIcon +} from 'lucide-react' +import Github from '../components/icons/Github' import ConfirmDialog from '../components/ConfirmDialog' import Combobox from '../components/Combobox' import UserSearchField from '../components/UserSearchField' @@ -16,7 +37,31 @@ type PendingAction = | { kind: 'cancel'; id: number; login: string } | { kind: 'role'; login: string; role: RepositoryRole } -const AVATAR_SIZE = 32 +const AVATAR_SIZE = 36 + +/** One glyph per standard role, so the list reads at a glance. */ +const ROLE_ICONS: Record = { + read: Eye, + triage: Tags, + write: PenLine, + maintain: Wrench, + admin: ShieldCheck +} + +/** A role as a quiet chip; custom roles get the chip without a glyph. */ +function RoleChip({ role, label }: { role: string; label: string }): JSX.Element { + const Icon = isStandardRole(role) ? ROLE_ICONS[role] : null + return ( + + {Icon && } + {label} + + ) +} function isStandardRole(role: string): role is RepositoryRole { return (REPOSITORY_ROLES as readonly string[]).includes(role) @@ -102,27 +147,132 @@ function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHub setPending(null) } + // How many people hold each role, in the order GitHub ranks them. + const roleCounts = data + ? REPOSITORY_ROLES.map((role) => ({ role, count: data.collaborators.filter((c) => c.role === role).length })) + .filter((r) => r.count > 0) + .reverse() + : [] + + const addForm = data?.canAdminister ? ( +
+

+ + {t.addTitle} +

+ {/* Visible overflow: the group would clip the permission list otherwise. */} +
void handleAdd(event)} className="settings-group overflow-visible"> +
+
+ + { + setUsername(value) + if (shownFieldError) clearFieldError() + }} + statusOf={accessStatus} + placeholder={t.usernamePlaceholder} + invalid={Boolean(shownFieldError)} + describedBy={`${baseId}-hint`} + /> +
+ {isOrg && ( +
+

{t.roleLabel}

+ setNewRole(value as RepositoryRole)} + options={roleOptions()} + ariaLabel={t.roleLabel} + disabled={isSaving} + /> +
+ )} + + {shownFieldError ? ( + + ) : ( +

+ {t.addHint} +

+ )} +
+
+
+ ) : ( +
+
+ + + +

{t.readOnly}

+
+
+ ) + return (
-
-
-
-

+
+
+
+

+ {t.title} {data && data.collaborators.length > 0 && {data.collaborators.length}}

-

{repoName}

+
+ + + {repoName} + + {roleCounts.length > 0 && ( + + {roleCounts.map(({ role, count }) => { + const Icon = ROLE_ICONS[role] + return ( + + + {count} + {t.roles[role]} + + ) + })} + + )} +
+
+
+ + + {t.manageOnGithub} + +
-
{loadError ? ( @@ -144,159 +294,106 @@ function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHub

{t.noAccess}

) : ( - <> - {data.canAdminister ? ( -
-

- {t.addTitle} -

- {/* Visible overflow: the group would clip the permission list otherwise. */} -
void handleAdd(event)} className="settings-group overflow-visible"> -
-
-
- - { - setUsername(value) - if (shownFieldError) clearFieldError() - }} - statusOf={accessStatus} - placeholder={t.usernamePlaceholder} - invalid={Boolean(shownFieldError)} - describedBy={`${baseId}-hint`} - /> -
- {isOrg && ( -
-

{t.roleLabel}

- setNewRole(value as RepositoryRole)} - options={roleOptions()} - ariaLabel={t.roleLabel} - disabled={isSaving} - className="mt-1.5" - /> -
- )} - -
- {shownFieldError ? ( - - ) : ( -

- {t.addHint} -

- )} -
-
-
- ) : ( -
-
- - - -

{t.readOnly}

-
-
- )} + // Wide: people on the left, the invite card held beside them. Narrow: + // one column, inviting first. +
+ - {data.invitationsError && ( -

- - {t.invitationsFailed(data.invitationsError)} -

- )} +
+ {data.invitationsError && ( +

+ + {t.invitationsFailed(data.invitationsError)} +

+ )} - {data.invitations.length > 0 && ( -
-

- {t.pendingTitle} - {data.invitations.length} -

-
    - {data.invitations.map((invitation) => { - const name = invitation.login ?? t.unknownInvitee - return ( -
  • - -
    -

    {name}

    -

    - {roleLabel(invitation.role)} · {t.invitedAt(relativeTime(locale, invitation.createdAt))} -

    -
    -
    - - - {invitation.expired ? t.expiredChip : t.pendingChip} - - -
    -
  • - ) - })} -
-
- )} + {data.invitations.length > 0 && ( +
+

+ + {t.pendingTitle} + {data.invitations.length} +

+
    + {data.invitations.map((invitation, index) => { + const name = invitation.login ?? t.unknownInvitee + return ( +
  • + +
    +

    {name}

    +

    {t.invitedAt(relativeTime(locale, invitation.createdAt))}

    +
    +
    + + + + {invitation.expired ? t.expiredChip : t.pendingChip} + + +
    +
  • + ) + })} +
+
+ )} -
-

- {t.peopleTitle} -

- {data.collaborators.length === 0 ? ( - } title={t.empty} /> - ) : ( -
    - {data.collaborators.map((collaborator) => { - const { login, role, direct } = collaborator - const manageable = canManageCollaborator(collaborator, { - canAdminister: data.canAdminister, - owner: data.ownerLogin, - viewer: user.login - }) - const roleEditable = manageable && isOrg - const details = [roleEditable ? null : roleLabel(role), isOrg ? (direct ? t.direct : t.inherited) : null] - .filter(Boolean) - .join(' · ') - return ( -
  • - -
    -

    - {login} - {login.toLowerCase() === user.login.toLowerCase() && {t.you}} - {login.toLowerCase() === data.ownerLogin.toLowerCase() && {t.owner}} -

    -

    - {details} -

    -
    - {manageable && ( +
    +

    + + {t.peopleTitle} +

    + {data.collaborators.length === 0 ? ( + } title={t.empty} /> + ) : ( +
      + {data.collaborators.map((collaborator, index) => { + const { login, role, direct } = collaborator + const manageable = canManageCollaborator(collaborator, { + canAdminister: data.canAdminister, + owner: data.ownerLogin, + viewer: user.login + }) + const roleEditable = manageable && isOrg + return ( +
    • + +
      +

      + {login} + {login.toLowerCase() === user.login.toLowerCase() && {t.you}} + {login.toLowerCase() === data.ownerLogin.toLowerCase() && {t.owner}} +

      + {isOrg && ( +

      + {direct ? : } + {direct ? t.direct : t.inherited} +

      + )} +
      - {roleEditable && ( + {roleEditable ? ( { @@ -307,26 +404,30 @@ function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHub disabled={isSaving} className="w-36" /> + ) : ( + + )} + {manageable && ( + )} -
      - )} -
    • - ) - })} -
    - )} -
    - +
  • + ) + })} +
+ )} +
+
+
)}
diff --git a/GitWeb/src/renderer/src/views/InitialView.tsx b/GitWeb/src/renderer/src/views/InitialView.tsx index 273f857..770c9c8 100644 --- a/GitWeb/src/renderer/src/views/InitialView.tsx +++ b/GitWeb/src/renderer/src/views/InitialView.tsx @@ -12,6 +12,7 @@ import { Folder, FolderInput, GitBranch, + GitCommitHorizontal, Pencil, CloudOff, DownloadCloud, @@ -44,9 +45,10 @@ import type { RepoUpdateStatus } from '../../../shared/git' import type { Messages } from '../../../shared/i18n' -import { useT } from '../lib/i18n' -import ThemeToggle from '../components/ThemeToggle' +import { useLocale, useT } from '../lib/i18n' +import { relativeTime } from '../lib/relativeTime' import RepoLane, { LaneMark } from '../components/RepoLane' +import BenchPulse, { WINDOW_DAYS } from '../components/BenchPulse' type InitialMessages = Messages['initial'] @@ -77,6 +79,17 @@ function FetchCheckBadge({ check }: { check?: RepoUpdateStatus }): JSX.Element | } } +/** The header summary's dot colours, for one repo: the same legend, reused. */ +function railTone(check?: RepoUpdateStatus): string { + if (!check || check.state === 'checking') return 'bg-tertiary-text/50' + if (check.state === 'failed') return 'bg-danger-red' + if (check.state === 'diverged') return 'bg-warning-amber' + if (check.state === 'behind') return 'bg-attention-orange' + if (check.localChanges > 0) return 'bg-command-blue' + if (check.state === 'upToDate') return 'bg-success-green' + return 'bg-tertiary-text/50' +} + interface StatusChip { icon: LucideIcon value: string @@ -254,6 +267,7 @@ function InitialView(): JSX.Element { const setView = useRepoStore((s) => s.setView) const messages = useT() const t = messages.initial + const locale = useLocale() // Single source of truth for "is the user signed in to GitHub" — shared // with CloneModal's GitHub tab, so cloning reflects an existing login @@ -409,16 +423,18 @@ function InitialView(): JSX.Element { return { behind, diverged, changes, ok, failed, checking } })() - function renderRepoCard(repo: RecentRepository): JSX.Element { + function renderRepoCard(repo: RecentRepository, index: number): JSX.Element { const check = repo.missing ? undefined : recentRepoChecks[repo.path] const result = batchUpdateResults[repo.path] const selectable = Boolean(check?.canFastForward && !check.refreshing) const chips = check && check.state !== 'checking' ? repoStatusChips(t, check) : [] const laneTitle = chips.length > 0 ? chips.map((c) => c.title).join('\n') : check?.message + const lastCommit = check?.lastCommit ?? null return ( - // Dragging is the pointer shortcut; the row's "move to folder" button does the same from the keyboard. - // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + // The row click is a pointer convenience only: keyboard users open the + // repo through the button inside it, which stays the accessible target. + // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
  • setDraggingPath(null)} - className={`workbench-row group flex cursor-grab items-center gap-1 py-1.5 pl-2 pr-1.5 ${repo.missing ? 'opacity-60' : ''}`} + // The whole row reads as one target (it highlights on hover), so a click + // anywhere on it opens the repo — except on its own controls and menu. + onClick={(e) => { + if ((e.target as HTMLElement).closest('button, input, a, [role="menu"]')) return + if (!isBusy && !repo.missing) openRepository(repo.path) + }} + style={{ animationDelay: `${Math.min(index, 12) * 28}ms` }} + className={`workbench-row workbench-row-enter group flex ${repo.missing ? 'cursor-grab' : 'cursor-pointer'} items-center gap-1 py-1.5 pl-2 pr-1.5 ${repo.missing ? 'opacity-60' : ''}`} > {showSelection && ( @@ -477,33 +500,51 @@ function InitialView(): JSX.Element { - {/* State, said in numbers: the lane draws it, these count it. */} + {/* State, said in numbers: the lane draws it, these count it. A healthy + repo has nothing to count, so the column tells what last happened + in it instead — the one thing that tells two green rows apart. */} {!repo.missing && ( - + {chips.length > 0 ? ( - chips.map((chip) => ( - - - {chip.value} - - )) - ) : ( + + {chips.map((chip) => ( + + + {chip.value} + + ))} + + ) : check?.state === 'failed' || !lastCommit ? ( {check?.message ?? ''} + ) : ( + + + {lastCommit.subject} + + )} + {lastCommit && ( + )} )} - + {/* Keyboard-reachable equivalent of dragging the row. */} +
  • + ) + })} + + + )}

    -
    - {user && ( -
    - - - - - - {user.name || user.login} - - @{user.login} · {t.githubConnected} + {/* Who is signed in — drawn as HEAD, with the bench's recent commits + trailing off its branch. The three places outside the bench ride + quietly at the end of that same row instead of a block of their own. */} +
    +
    +
    + {user && ( + + + + + + + {user.name || user.login} + + + {user.login} + · {t.githubConnected} + + - + )} +
    - )} -
    - {messagesLink} - - + {user && ( + + )}
    @@ -708,7 +784,6 @@ function InitialView(): JSX.Element {

    )}
    -
    {/* While cloning, the modal shows this same error inside itself. */} @@ -821,12 +896,12 @@ function InitialView(): JSX.Element { )} {/* Narrow windows hide the rail's footer; its links live here instead. */} -