Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ The palette is restrained at rest and categorical in motion: neutrals carry the

**The Data-Viz Exception.** Charts on the Dashboard are the one place hue encodes *identity* (a language, a contributor) instead of action or state. They use their own `--viz-1…8` categorical set and a `--viz-seq-1…5` blue ramp (defined in `global.css`), validated for colour-vision deficiency against the real panel surfaces, separately stepped for dark and light. Chart text stays in text tokens; every coloured mark has a visible text label beside it.

**The Activity Sky Exception.** The Messages screen is the one operational surface allowed atmosphere, by explicit product decision: the account's last 30 days are drawn as a night sky, and that sky is a real spatial canvas plotting real data. Inside `.sky` it may use soft radial washes of Command Blue and Warning Amber, a bottom fade into Midnight Base, decorative dust, a drop-shadow glow on star marks, and a glint on new stars. Amber here encodes identity (a GitHub star), not caution; `--sky-star` replaces Warning Amber in the light theme, whose text-grade value reads brown as a mark. The invitation card just below (`.inbox-invite`) may carry a soft tint and a colored offset shadow, because it is the screen's one decision. High-contrast themes drop the washes, dust, glints, glow, card tint and colored shadow; `prefers-reduced-motion` stops all of the movement. This exception does not extend to any other screen.

**The Red Means Danger Rule.** Danger Red is reserved for destructive actions, failures, and intervention. It is forbidden as decorative emphasis.

**The Contrast Before Atmosphere Rule.** Readability wins over subtlety. Muted text and placeholders must meet WCAG 2.2 AA against their actual surface, in every theme variant, not only the dark default.
Expand Down Expand Up @@ -339,7 +341,8 @@ OpenGitDesktop uses structural layering. Midnight Base, Obsidian Panel, and Coal
- **View enter**: switching tabs brings the incoming view up 6px with a 3px de-blur over 280ms (expo ease-out). It uses `backwards` fill only, so no transform or filter lingers to trap `position: fixed` menus.
- **List selection**: rows mark hover and selection with a short rounded accent pill on the leading edge that grows from the row's middle (`--row-accent` recolors it; conflicts use Warning Amber).
- **Tab indicator**: the active tab's underline grows from its center.
- **Assistant standby**: the empty-chat emblem breathes a soft Command Blue ring. It is the only idle loop in the app.
- **Assistant standby**: the empty-chat emblem breathes a soft Command Blue ring. Outside the Activity Sky, it is the only idle loop in the app.
- **Activity Sky**: on Messages, the graph line draws itself once on entry, and a few dust points, new stars and the "now" marker keep moving quietly. Anything that loops lives on its own layer outside the SVG, so it never repaints the sky.
- All of it collapses under `prefers-reduced-motion`.

### Named Rules
Expand Down Expand Up @@ -411,8 +414,8 @@ Switches use a `42px` by `24px` track with an `18px` thumb and explicit text lab
- **Do** require explicit confirmation for destructive Git actions (discard, abort merge, drop stash, pop stash, merge or close a pull request). Danger confirms focus Cancel, not the destructive button.

### Don't:
- **Don't** turn OpenGitDesktop into glossy generic SaaS marketing with decorative gradients, hero-metric templates, or glass cards.
- **Don't** use neon glows or speculative hype aesthetics.
- **Don't** turn OpenGitDesktop into glossy generic SaaS marketing with decorative gradients, hero-metric templates, or glass cards. The Activity Sky Exception is the only allowance, and only on Messages.
- **Don't** use neon glows or speculative hype aesthetics. The restrained star glow inside the Activity Sky is the only exception.
- **Don't** reduce the product to an undifferentiated CRUD admin panel; keep the daily Git path (changes → diff → commit) primary.
- **Don't** compete with that path using equal-weight header cards or icon-only Settings/About buttons; Settings is a labeled tab, About lives inside it.
- **Don't** use decorative grid backgrounds unless the surface is an actual charting or spatial canvas.
Expand Down
189 changes: 188 additions & 1 deletion GitWeb/src/main/githubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import type {
BranchRules,
CreatePullRequestInput,
GitHubRepoSummary,
GitHubNotification,
GitHubUser,
GitHubUserSuggestion,
MergeMethod,
PullRequest,
PullRequestCheck,
Expand All @@ -16,10 +18,12 @@ import type {
PullRequestReviewSummary,
PullRequestState,
PullRequestStatus,
ReceivedInvitation,
RepositoryCollaborators,
RepositoryInvitation,
RepositoryOwner,
RepositoryRole,
RepositoryStar,
ReviewDecision
} from '../shared/github'

Expand Down Expand Up @@ -100,7 +104,7 @@ async function send<T>(
}

const next = nextPageUrl(response.headers.get('link'))
if (response.status === 204) return { data: undefined as T, next }
if (response.status === 204 || response.status === 205) return { data: undefined as T, next }
return { data: (await response.json()) as T, next }
}

Expand Down Expand Up @@ -813,6 +817,27 @@ export async function getRepositoryCollaborators(host: string, owner: string, re
}
}

const USER_SEARCH_LIMIT = 8

/**
* Accounts matching a login or profile name. GraphQL because the REST search
* leaves the name out, and the name is how someone recognizes the right person.
*/
export async function searchUsers(host: string, query: string): Promise<GitHubUserSuggestion[]> {
const token = await requireToken(host)
const { search } = await graphql<{
search: { nodes: ({ login?: string; name?: string | null; avatarUrl?: string } | null)[] }
}>(
host,
token,
'query($q: String!, $first: Int!) { search(query: $q, type: USER, first: $first) { nodes { ... on User { login name avatarUrl } } } }',
{ q: `${query} type:user`, first: USER_SEARCH_LIMIT }
)
return search.nodes.flatMap((node) =>
node?.login ? [{ login: node.login, name: node.name || null, avatarUrl: node.avatarUrl ?? '' }] : []
)
}

/**
* Adds a person, or changes the role of one already there — GitHub has a single
* endpoint for both. Without a role GitHub applies its default, and on personal
Expand Down Expand Up @@ -851,3 +876,165 @@ export async function cancelRepositoryInvitation(host: string, owner: string, re
const token = await requireToken(host)
await request(host, `${repositoryPath(owner, repo)}/invitations/${invitationId}`, token, { method: 'DELETE' })
}

// --- What the Messages tab shows ------------------------------------------------

/**
* Runs a read that happens on every start, signed in or not: null means there is
* no working sign-in, which is not an error. Doesn't delete a dead token —
* getSession already owns that decision.
*/
async function whenSignedIn<T>(host: string, read: (token: string) => Promise<T>): Promise<T | null> {
const token = await getSecret(credentialKey(host))
if (!token) return null
try {
return await read(token)
} catch (err) {
if (err instanceof GitHubApiError && err.status === 401) return null
throw err
}
}

interface ApiNotification {
id: string
reason: string
updated_at: string
subject: { title: string; type: string; url: string | null }
repository: { full_name: string; html_url: string }
}

/** The page to open: GitHub only gives the API address of a subject, and not even that for some kinds. */
function notificationWebUrl({ subject, repository }: ApiNotification): string {
const repo = repository.html_url
const match = subject.url?.match(/\/(pulls|issues|commits)\/([^/]+)$/)
if (match) {
const section = { pulls: 'pull', issues: 'issues', commits: 'commit' }[match[1]]
return `${repo}/${section}/${match[2]}`
}
if (subject.type === 'Release') return `${repo}/releases`
if (subject.type === 'Discussion') return `${repo}/discussions`
if (subject.type === 'CheckSuite') return `${repo}/actions`
return repo
}

/** Unread notifications, newest first. Invitations are left out: they have their own list, with their own actions. */
export function getNotifications(host: string): Promise<GitHubNotification[] | null> {
return whenSignedIn(host, async (token) => {
const notifications = await request<ApiNotification[]>(host, '/notifications?per_page=50', token)
return notifications
.filter((notification) => notification.subject.type !== 'RepositoryInvitation')
.map((notification) => ({
id: notification.id,
kind: notification.subject.type,
reason: notification.reason,
title: notification.subject.title,
repoFullName: notification.repository.full_name,
htmlUrl: notificationWebUrl(notification),
updatedAt: notification.updated_at
}))
})
}

export async function markNotificationRead(host: string, threadId: string): Promise<void> {
const token = await requireToken(host)
await request(host, `/notifications/threads/${encodeURIComponent(threadId)}`, token, { method: 'PATCH' })
}

/** Up to `lastReadAt`, so something that arrives after the list was read stays unread. */
export async function markAllNotificationsRead(host: string, lastReadAt: string): Promise<void> {
const token = await requireToken(host)
await request(host, '/notifications', token, { method: 'PUT', body: JSON.stringify({ last_read_at: lastReadAt }) })
}

const RECENT_STARS_DAYS = 30
const RECENT_STARS_LIMIT = 20

/**
* GitHub doesn't notify about stars, so they are read from the repositories
* themselves: the latest few of each is enough to catch up between checks.
* Costs a single GraphQL request however many repositories there are.
*/
export function getRecentStars(host: string): Promise<RepositoryStar[] | null> {
return whenSignedIn(host, async (token) => {
const { viewer } = await graphql<{
viewer: {
login: string
repositories: {
nodes: {
nameWithOwner: string
stargazers: { edges: { starredAt: string; node: { login: string; avatarUrl: string; url: string } }[] }
}[]
}
}
}>(
host,
token,
'query { viewer { login repositories(first: 100, ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], orderBy: { field: PUSHED_AT, direction: DESC }) { nodes { nameWithOwner stargazers(first: 5, orderBy: { field: STARRED_AT, direction: DESC }) { edges { starredAt node { login avatarUrl url } } } } } } }',
{}
)
const since = Date.now() - RECENT_STARS_DAYS * 24 * 60 * 60 * 1000
return viewer.repositories.nodes
.flatMap((repository) =>
repository.stargazers.edges.map((edge) => ({
repoFullName: repository.nameWithOwner,
login: edge.node.login,
avatarUrl: edge.node.avatarUrl,
profileUrl: edge.node.url,
starredAt: edge.starredAt
}))
)
.filter((star) => star.login !== viewer.login && Date.parse(star.starredAt) >= since)
.sort((a, b) => Date.parse(b.starredAt) - Date.parse(a.starredAt))
.slice(0, RECENT_STARS_LIMIT)
})
}

interface ApiReceivedInvitation {
id: number
html_url: string
permissions: string
created_at: string
expired?: boolean
repository: { full_name: string; private?: boolean }
inviter: { login: string; avatar_url?: string } | null
}

/** Pending invitations. Expired ones are left out because GitHub no longer lets anyone act on them. */
export function getReceivedInvitations(host: string): Promise<ReceivedInvitation[] | null> {
return whenSignedIn(host, async (token) => {
const invitations = await requestAll<ApiReceivedInvitation>(host, '/user/repository_invitations?per_page=100', token)
return invitations
.filter((invitation) => !invitation.expired)
.map((invitation) => ({
id: invitation.id,
repoFullName: invitation.repository.full_name,
htmlUrl: invitation.html_url,
isPrivate: Boolean(invitation.repository.private),
inviterLogin: invitation.inviter?.login ?? null,
inviterAvatarUrl: invitation.inviter?.avatar_url ?? null,
role: invitation.permissions,
createdAt: invitation.created_at
}))
})
}

/** A 404 here means the invitation was cancelled, already answered, or expired since the list was read. */
async function answerInvitation(host: string, invitationId: number, method: 'PATCH' | 'DELETE'): Promise<void> {
const token = await requireToken(host)
try {
await request(host, `/user/repository_invitations/${invitationId}`, token, { method })
} catch (err) {
if (err instanceof GitHubApiError && err.status === 404) {
throw new GitHubApiError(tMain().github.invitationGone, err.status)
}
throw err
}
}

export function acceptInvitation(host: string, invitationId: number): Promise<void> {
return answerInvitation(host, invitationId, 'PATCH')
}

export function declineInvitation(host: string, invitationId: number): Promise<void> {
return answerInvitation(host, invitationId, 'DELETE')
}
36 changes: 35 additions & 1 deletion GitWeb/src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ipcMain, app, BrowserWindow, type WebFrameMain } from 'electron'
import { ipcMain, app, BrowserWindow, Notification, type WebContents, type WebFrameMain } from 'electron'
import {
abortMerge,
applyPatch,
Expand Down Expand Up @@ -145,7 +145,33 @@ function handle(channel: string, listener: Parameters<typeof ipcMain.handle>[1])
})
}

// Held until dismissed: Electron drops the click handler of a notification that gets garbage collected.
const liveNotifications = new Set<Notification>()

/** Lives here because the session's permission handler refuses the renderer's own Notification API. */
function showDesktopNotification(sender: WebContents, title: string, body: string): void {
if (!Notification.isSupported()) return
const notification = new Notification({ title, body })
liveNotifications.add(notification)
notification.on('close', () => liveNotifications.delete(notification))
notification.on('failed', (_event, error) => {
liveNotifications.delete(notification)
console.error('Desktop notification failed:', error)
})
notification.on('click', () => {
liveNotifications.delete(notification)
if (sender.isDestroyed()) return
const window = BrowserWindow.fromWebContents(sender)
if (window?.isMinimized()) window.restore()
window?.show()
window?.focus()
sender.send('app:notification-clicked')
})
notification.show()
}

export function registerIpcHandlers(): void {
handle('app:showNotification', (event, title: string, body: string) => showDesktopNotification(event.sender, title, body))
handle('app:getVersion', () => app.getVersion())
handle('app:getUpdateStatus', () => getUpdateStatus())
handle('app:checkForUpdates', () => checkForUpdates())
Expand Down Expand Up @@ -353,6 +379,14 @@ export function registerIpcHandlers(): void {
handle('github:getRepositoryCollaborators', (_e, host: string, owner: string, repo: string) =>
githubApi.getRepositoryCollaborators(host, owner, repo)
)
handle('github:getReceivedInvitations', (_e, host: string) => githubApi.getReceivedInvitations(host))
handle('github:acceptInvitation', (_e, host: string, invitationId: number) => githubApi.acceptInvitation(host, invitationId))
handle('github:declineInvitation', (_e, host: string, invitationId: number) => githubApi.declineInvitation(host, invitationId))
handle('github:getNotifications', (_e, host: string) => githubApi.getNotifications(host))
handle('github:markNotificationRead', (_e, host: string, threadId: string) => githubApi.markNotificationRead(host, threadId))
handle('github:markAllNotificationsRead', (_e, host: string, lastReadAt: string) => githubApi.markAllNotificationsRead(host, lastReadAt))
handle('github:getRecentStars', (_e, host: string) => githubApi.getRecentStars(host))
handle('github:searchUsers', (_e, host: string, query: string) => githubApi.searchUsers(host, query))
handle('github:addCollaborator', (_e, host: string, owner: string, repo: string, username: string, role?: RepositoryRole) =>
githubApi.addCollaborator(host, owner, repo, username, role)
)
Expand Down
21 changes: 20 additions & 1 deletion GitWeb/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ import type {
PublishRepositoryInput,
PublishRepositoryResult,
AddCollaboratorResult,
GitHubNotification,
GitHubUserSuggestion,
ReceivedInvitation,
RepositoryCollaborators,
RepositoryOwner,
RepositoryRole
RepositoryRole,
RepositoryStar
} from '../shared/github'
import type { UpdateStatus } from '../shared/updates'

Expand All @@ -66,6 +70,9 @@ export interface Api {
checkForUpdates: () => Promise<UpdateStatus>
quitAndInstall: () => Promise<void>
capturePage: (rect: { x: number; y: number; width: number; height: number }) => Promise<string>
/** A system notification; clicking it brings the window forward and fires onNotificationClicked. */
showNotification: (title: string, body: string) => Promise<void>
onNotificationClicked: (callback: () => void) => () => void
onUpdateStatus: (callback: (status: UpdateStatus) => void) => () => void
}
git: {
Expand Down Expand Up @@ -198,6 +205,18 @@ export interface Api {
listUserReposWithToken: (host: string, token: string) => Promise<GitHubRepoSummary[]>

getRepositoryCollaborators: (host: string, owner: string, repo: string) => Promise<RepositoryCollaborators>
/** Invitations this account received; null when it isn't signed in on that host. */
getReceivedInvitations: (host: string) => Promise<ReceivedInvitation[] | null>
acceptInvitation: (host: string, invitationId: number) => Promise<void>
declineInvitation: (host: string, invitationId: number) => Promise<void>
/** Unread GitHub notifications; null when not signed in on that host. */
getNotifications: (host: string) => Promise<GitHubNotification[] | null>
markNotificationRead: (host: string, threadId: string) => Promise<void>
markAllNotificationsRead: (host: string, lastReadAt: string) => Promise<void>
/** Recent stars on this account's repositories; null when not signed in on that host. */
getRecentStars: (host: string) => Promise<RepositoryStar[] | null>
/** Accounts matching a login or profile name, for suggesting who to invite. */
searchUsers: (host: string, query: string) => Promise<GitHubUserSuggestion[]>
/** Invites a person, or changes the role of one already there. */
addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => Promise<AddCollaboratorResult>
removeCollaborator: (host: string, owner: string, repo: string, username: string) => Promise<void>
Expand Down
Loading
Loading