diff --git a/GitWeb/README.en.md b/GitWeb/README.en.md index 3c5a825..b07964b 100644 --- a/GitWeb/README.en.md +++ b/GitWeb/README.en.md @@ -78,6 +78,7 @@ src/ - Stash: create, apply, pop, drop — wired into the main navigation. - Sync: fetch/pull/push/publish branch that adapts to the ahead/behind/upstream state. - Pull Requests: sign in with OAuth Device Flow (GitHub.com) or PAT (GitHub.com or Enterprise), list, create, view files/comments, merge, close/reopen. +- Collaborators: see who has access, invite by username, cancel invitations, remove people and change permissions (on organization repositories), right in the app. - AI chat with tools (create/switch/merge branch, commit, push/pull/fetch, stash, list/create PR) with a human-approval step before each action. - Interface, main-process messages and AI answers in Portuguese (Brazil), English or Spanish — following the system language by default, switchable live in Settings. - Automatic light/dark themes (from the system preference) and high-contrast support (Windows High Contrast Mode / `prefers-contrast`). diff --git a/GitWeb/README.es.md b/GitWeb/README.es.md index 5ef723b..8a5418f 100644 --- a/GitWeb/README.es.md +++ b/GitWeb/README.es.md @@ -78,6 +78,7 @@ src/ - Stash: crear, aplicar, pop, descartar — conectado a la navegación principal. - Sincronización: fetch/pull/push/publicar rama que se adapta al estado ahead/behind/upstream. - Pull Requests: inicio de sesión con OAuth Device Flow (GitHub.com) o PAT (GitHub.com o Enterprise), listar, crear, ver archivos/comentarios, fusionar, cerrar/reabrir. +- Colaboradores: ver quién tiene acceso, invitar por usuario, cancelar invitaciones, quitar personas y cambiar permisos (en repositorios de organización), desde la app. - Chat de IA con herramientas (crear/cambiar/fusionar rama, commit, push/pull/fetch, stash, listar/crear PR) con aprobación humana antes de cada acción. - Interfaz, mensajes del proceso principal y respuestas de la IA en portugués (Brasil), inglés o español — sigue el idioma del sistema por defecto y se puede cambiar al instante en Configuración. - Temas claro/oscuro automáticos (según la preferencia del sistema) y soporte de alto contraste (Windows High Contrast Mode / `prefers-contrast`). diff --git a/GitWeb/README.md b/GitWeb/README.md index 0520a84..a502351 100644 --- a/GitWeb/README.md +++ b/GitWeb/README.md @@ -78,6 +78,7 @@ src/ - Stash: criar, aplicar, pop, descartar — conectado à navegação principal. - Sync: fetch/pull/push/publicar branch adaptativo conforme o estado ahead/behind/upstream. - Pull Requests: login com OAuth Device Flow (GitHub.com) ou PAT (GitHub.com ou Enterprise), listar, criar, ver arquivos/comentários, mesclar, fechar/reabrir. +- Colaboradores: ver quem tem acesso, convidar por username, cancelar convites, remover pessoas e mudar permissões (em repositórios de organização), direto no app. - Chat de IA com ferramentas (criar/trocar/mesclar branch, commit, push/pull/fetch, stash, listar/criar PR) com fluxo de aprovação humana antes de cada ação. - Interface, mensagens do processo principal e respostas da IA em português (Brasil), inglês ou espanhol — segue o idioma do sistema por padrão e pode ser trocado na hora em Configurações. - Temas claro/escuro automáticos (via preferência do sistema) e suporte a alto contraste (Windows High Contrast Mode / `prefers-contrast`). diff --git a/GitWeb/src/main/githubApi.ts b/GitWeb/src/main/githubApi.ts index 766bd98..145503e 100644 --- a/GitWeb/src/main/githubApi.ts +++ b/GitWeb/src/main/githubApi.ts @@ -3,6 +3,7 @@ import { getSecret, saveSecret, deleteSecret } from './credentials' import { httpFetch } from './httpFetch' import { tMain } from './i18n' import type { + AddCollaboratorResult, BranchRules, CreatePullRequestInput, GitHubRepoSummary, @@ -15,7 +16,10 @@ import type { PullRequestReviewSummary, PullRequestState, PullRequestStatus, + RepositoryCollaborators, + RepositoryInvitation, RepositoryOwner, + RepositoryRole, ReviewDecision } from '../shared/github' @@ -711,3 +715,139 @@ export async function createRepository( }) return { fullName: repo.full_name, htmlUrl: repo.html_url ?? `https://${host}/${repo.full_name}`, cloneUrl: repo.clone_url } } + +// --- Collaborators ------------------------------------------------------------ + +interface ApiCollaborator { + login: string + avatar_url: string + role_name: string +} + +interface ApiInvitation { + id: number + invitee: { login: string; avatar_url?: string } | null + permissions: string + created_at: string + expired?: boolean +} + +// The write endpoint still takes the role names GitHub used before read/write. +const API_PERMISSION: Record = { + read: 'pull', + triage: 'triage', + write: 'push', + maintain: 'maintain', + admin: 'admin' +} + +function repositoryPath(owner: string, repo: string): string { + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` +} + +function toInvitation(invitation: ApiInvitation): RepositoryInvitation { + return { + id: invitation.id, + login: invitation.invitee?.login ?? null, + avatarUrl: invitation.invitee?.avatar_url ?? null, + role: invitation.permissions, + createdAt: invitation.created_at, + expired: Boolean(invitation.expired) + } +} + +/** + * Who has access and what the signed-in account may do about it. Invitations + * are allowed to fail on their own: a list of collaborators without them is + * still worth showing. + */ +export async function getRepositoryCollaborators(host: string, owner: string, repo: string): Promise { + const token = await requireToken(host) + const base = repositoryPath(owner, repo) + const info = await request<{ owner: { login: string; type: string }; permissions?: { admin?: boolean; push?: boolean } }>(host, base, token) + const ownerLogin = info.owner.login + const ownerKind = info.owner.type === 'Organization' ? 'org' : 'user' + const canAdminister = Boolean(info.permissions?.admin) + const canView = canAdminister || Boolean(info.permissions?.push) + if (!canView) return { ownerLogin, ownerKind, canView, canAdminister, collaborators: [], invitations: [], invitationsError: null } + + const list = (affiliation: 'all' | 'direct'): Promise => + requestAll(host, `${base}/collaborators?affiliation=${affiliation}&per_page=100`, token) + + let everyone: ApiCollaborator[] + let direct: Set | null = null + try { + // On a personal repository everyone is direct, so asking twice would only cost a request. + const [all, directOnly] = await Promise.all([list('all'), ownerKind === 'org' ? list('direct') : null]) + everyone = all + if (directOnly) direct = new Set(directOnly.map((c) => c.login)) + } catch (err) { + // An organization can refuse this list to a sign-in that works everywhere + // else, so a bare "Forbidden" would not say where to go next. + if (ownerKind === 'org' && err instanceof GitHubApiError && (err.status === 403 || err.status === 404)) { + throw new GitHubApiError(tMain().github.collaboratorsForbidden(err.message), err.status) + } + throw err + } + + let invitations: RepositoryInvitation[] = [] + let invitationsError: string | null = null + if (canAdminister) { + try { + invitations = (await requestAll(host, `${base}/invitations?per_page=100`, token)).map(toInvitation) + } catch (err) { + invitationsError = err instanceof Error ? err.message : String(err) + } + } + + return { + ownerLogin, + ownerKind, + canView, + canAdminister, + collaborators: everyone + .map((c) => ({ login: c.login, avatarUrl: c.avatar_url, role: c.role_name, direct: direct ? direct.has(c.login) : true })) + .sort((a, b) => a.login.localeCompare(b.login, undefined, { sensitivity: 'base' })), + invitations, + invitationsError + } +} + +/** + * 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 + * repositories a role is not accepted at all. + */ +export async function addCollaborator( + host: string, + owner: string, + repo: string, + username: string, + role?: RepositoryRole +): Promise { + const token = await requireToken(host) + try { + const invitation = await request( + host, + `${repositoryPath(owner, repo)}/collaborators/${encodeURIComponent(username)}`, + token, + { method: 'PUT', body: JSON.stringify(role ? { permission: API_PERMISSION[role] } : {}) } + ) + return invitation ? { kind: 'invited', invitation: toInvitation(invitation) } : { kind: 'added' } + } catch (err) { + if (err instanceof GitHubApiError && err.status === 404) { + throw new GitHubApiError(tMain().github.collaboratorNotFound(username), err.status) + } + throw err + } +} + +export async function removeCollaborator(host: string, owner: string, repo: string, username: string): Promise { + const token = await requireToken(host) + await request(host, `${repositoryPath(owner, repo)}/collaborators/${encodeURIComponent(username)}`, token, { method: 'DELETE' }) +} + +export async function cancelRepositoryInvitation(host: string, owner: string, repo: string, invitationId: number): Promise { + const token = await requireToken(host) + await request(host, `${repositoryPath(owner, repo)}/invitations/${invitationId}`, token, { method: 'DELETE' }) +} diff --git a/GitWeb/src/main/ipc.ts b/GitWeb/src/main/ipc.ts index 15872e7..1fe102d 100644 --- a/GitWeb/src/main/ipc.ts +++ b/GitWeb/src/main/ipc.ts @@ -85,7 +85,7 @@ import { saveSecret, getSecret, deleteSecret } from './credentials' import { startDeviceFlow, pollDeviceFlow, cancelDeviceFlow, getOAuthAppInfo } from './githubDeviceFlow' import * as githubApi from './githubApi' import { isAppOrigin } from './security' -import type { CreatePullRequestInput, MergeMethod, PublishRepositoryInput, PullRequestState } from '../shared/github' +import type { CreatePullRequestInput, MergeMethod, PublishRepositoryInput, PullRequestState, RepositoryRole } from '../shared/github' import { openDirectoryDialog } from './dialogs' import { addRecentRepositories, @@ -350,6 +350,19 @@ export function registerIpcHandlers(): void { githubApi.listUserReposWithToken(host, token) ) + handle('github:getRepositoryCollaborators', (_e, host: string, owner: string, repo: string) => + githubApi.getRepositoryCollaborators(host, owner, repo) + ) + handle('github:addCollaborator', (_e, host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => + githubApi.addCollaborator(host, owner, repo, username, role) + ) + handle('github:removeCollaborator', (_e, host: string, owner: string, repo: string, username: string) => + githubApi.removeCollaborator(host, owner, repo, username) + ) + handle('github:cancelRepositoryInvitation', (_e, host: string, owner: string, repo: string, invitationId: number) => + githubApi.cancelRepositoryInvitation(host, owner, repo, invitationId) + ) + handle('github:listPullRequests', (_e, host: string, owner: string, repo: string, state: PullRequestState) => githubApi.listPullRequests(host, owner, repo, state) ) diff --git a/GitWeb/src/preload/index.d.ts b/GitWeb/src/preload/index.d.ts index 1abf7fc..02699f5 100644 --- a/GitWeb/src/preload/index.d.ts +++ b/GitWeb/src/preload/index.d.ts @@ -52,7 +52,10 @@ import type { PullRequestStatus, PublishRepositoryInput, PublishRepositoryResult, - RepositoryOwner + AddCollaboratorResult, + RepositoryCollaborators, + RepositoryOwner, + RepositoryRole } from '../shared/github' import type { UpdateStatus } from '../shared/updates' @@ -194,6 +197,12 @@ export interface Api { publishRepository: (cwd: string, input: PublishRepositoryInput) => Promise listUserReposWithToken: (host: string, token: string) => Promise + getRepositoryCollaborators: (host: string, owner: string, repo: string) => Promise + /** Invites a person, or changes the role of one already there. */ + addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => Promise + removeCollaborator: (host: string, owner: string, repo: string, username: string) => Promise + cancelRepositoryInvitation: (host: string, owner: string, repo: string, invitationId: number) => Promise + listPullRequests: (host: string, owner: string, repo: string, state: PullRequestState) => Promise getPullRequest: (host: string, owner: string, repo: string, number: number) => Promise getBranchRules: (host: string, owner: string, repo: string, branch: string) => Promise diff --git a/GitWeb/src/preload/index.ts b/GitWeb/src/preload/index.ts index cd8e578..516ccf7 100644 --- a/GitWeb/src/preload/index.ts +++ b/GitWeb/src/preload/index.ts @@ -53,7 +53,10 @@ import type { PullRequestStatus, PublishRepositoryInput, PublishRepositoryResult, - RepositoryOwner + AddCollaboratorResult, + RepositoryCollaborators, + RepositoryOwner, + RepositoryRole } from '../shared/github' import type { UpdateStatus } from '../shared/updates' @@ -276,6 +279,15 @@ const api = { listUserReposWithToken: (host: string, token: string): Promise => invoke('github:listUserReposWithToken', host, token), + getRepositoryCollaborators: (host: string, owner: string, repo: string): Promise => + invoke('github:getRepositoryCollaborators', host, owner, repo), + addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole): Promise => + invoke('github:addCollaborator', host, owner, repo, username, role), + removeCollaborator: (host: string, owner: string, repo: string, username: string): Promise => + invoke('github:removeCollaborator', host, owner, repo, username), + cancelRepositoryInvitation: (host: string, owner: string, repo: string, invitationId: number): Promise => + invoke('github:cancelRepositoryInvitation', host, owner, repo, invitationId), + listPullRequests: (host: string, owner: string, repo: string, state: PullRequestState): Promise => invoke('github:listPullRequests', host, owner, repo, state), getPullRequest: (host: string, owner: string, repo: string, number: number): Promise => diff --git a/GitWeb/src/renderer/src/App.tsx b/GitWeb/src/renderer/src/App.tsx index f5f3f5b..a5ada11 100644 --- a/GitWeb/src/renderer/src/App.tsx +++ b/GitWeb/src/renderer/src/App.tsx @@ -13,6 +13,7 @@ import FileInsightPanel from './components/FileInsightPanel' import RebasePlannerDialog from './components/RebasePlannerDialog' import StashesView from './views/StashesView' import PullRequestsView from './views/PullRequestsView' +import CollaboratorsView from './views/CollaboratorsView' import DashboardView from './views/DashboardView' import AiChatView from './views/AiChatView' import SettingsView from './views/SettingsView' @@ -310,6 +311,7 @@ function App(): JSX.Element { {view === 'history' && } {view === 'stash' && } {view === 'pullRequests' && } + {view === 'collaborators' && } {view === 'dashboard' && } {view === 'settings' && } diff --git a/GitWeb/src/renderer/src/browserApiStub.ts b/GitWeb/src/renderer/src/browserApiStub.ts index 4b4f6a8..c48bc01 100644 --- a/GitWeb/src/renderer/src/browserApiStub.ts +++ b/GitWeb/src/renderer/src/browserApiStub.ts @@ -69,6 +69,31 @@ function demoGithub(): boolean { const DEMO_USER = { login: 'demo-dev', name: 'Demo Dev', avatarUrl: 'https://github.com/github.png' } +// localStorage.demoCollaborators picks which situation the Collaborators screen +// is designed against: 'org' | 'readOnly' | 'noAccess' | 'denied' (default: a +// personal repository administered by the demo user). +function demoCollaboratorVariant(): string | null { + try { + return localStorage.getItem('demoCollaborators') + } catch { + return null + } +} + +let demoCollaborators = [ + { login: 'demo', avatarUrl: '', role: 'admin', direct: true }, + { login: 'demo-dev', avatarUrl: '', role: 'admin', direct: true }, + { login: 'dependabot', avatarUrl: '', role: 'triage', direct: false }, + { login: 'hubot', avatarUrl: '', role: 'maintain', direct: false }, + { login: 'monalisa', avatarUrl: '', role: 'read', direct: true }, + { login: 'octocat', avatarUrl: '', role: 'write', direct: true } +] + +let demoInvitations = [ + { id: 2, login: 'torvalds', avatarUrl: null, role: 'write', createdAt: new Date(Date.now() - 3 * 3_600_000).toISOString(), expired: false }, + { id: 1, login: 'defunkt', avatarUrl: null, role: 'read', createdAt: new Date(Date.now() - 9 * 86_400_000).toISOString(), expired: true } +] + const hoursAgo = (h: number): string => new Date(Date.now() - h * 3600e3).toISOString() const DEMO_PRS = [ @@ -517,6 +542,47 @@ function installBrowserApiStub(): void { throw new Error(GITHUB_UNAVAILABLE) }, + getRepositoryCollaborators: async () => { + if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE) + const variant = demoCollaboratorVariant() + if (variant === 'denied') throw new Error('GitHub recusou a solicitação (403).') + if (variant === 'noAccess') { + return { ownerLogin: 'demo', ownerKind: 'user' as const, canView: false, canAdminister: false, collaborators: [], invitations: [], invitationsError: null } + } + const collaborators = + variant === 'org' + ? demoCollaborators + : demoCollaborators.filter((c) => c.direct).map((c) => (c.role === 'admin' ? c : { ...c, role: 'write' })) + return { + ownerLogin: 'demo', + ownerKind: variant === 'org' ? ('org' as const) : ('user' as const), + canView: true, + canAdminister: variant !== 'readOnly', + collaborators, + invitations: variant === 'readOnly' ? [] : demoInvitations, + invitationsError: null + } + }, + addCollaborator: async (_host, _owner, _repo, username, role) => { + if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE) + if (username.toLowerCase() === 'ghost-user') throw new Error(`O GitHub não encontrou ${username}, ou sua conta não pode gerenciar este repositório.`) + const existing = demoCollaborators.find((c) => c.login.toLowerCase() === username.toLowerCase()) + if (existing) { + if (role) existing.role = role + existing.direct = true + return { kind: 'added' as const } + } + const invitation = { id: Date.now(), login: username, avatarUrl: null, role: role ?? 'write', createdAt: new Date().toISOString(), expired: false } + demoInvitations = [invitation, ...demoInvitations] + return { kind: 'invited' as const, invitation } + }, + removeCollaborator: async (_host, _owner, _repo, username) => { + demoCollaborators = demoCollaborators.filter((c) => c.login !== username) + }, + cancelRepositoryInvitation: async (_host, _owner, _repo, invitationId) => { + demoInvitations = demoInvitations.filter((i) => i.id !== invitationId) + }, + listPullRequests: async (_host, _owner, _repo, state) => demoGithub() ? DEMO_PRS.filter((pr) => state === 'all' || pr.state === state) : [], getPullRequest: async (_host, _owner, _repo, number) => { diff --git a/GitWeb/src/renderer/src/components/Combobox.tsx b/GitWeb/src/renderer/src/components/Combobox.tsx index 09fab1b..397923c 100644 --- a/GitWeb/src/renderer/src/components/Combobox.tsx +++ b/GitWeb/src/renderer/src/components/Combobox.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { useClickOutside } from '../lib/useClickOutside' +import { opensUpward } from '../lib/dropdownPlacement' import { useT } from '../lib/i18n' export interface ComboboxOption { @@ -21,6 +22,24 @@ interface ComboboxProps { className?: string } +// max-h-64 and the height of one option row, for deciding where the list fits. +const LIST_MAX_HEIGHT = 256 +const OPTION_HEIGHT = 34 + +/** Room on each side of the element inside whatever clips it, not just the window. */ +function roomAround(element: HTMLElement): { above: number; below: number } { + const rect = element.getBoundingClientRect() + let top = 0 + let bottom = window.innerHeight + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (getComputedStyle(parent).overflowY === 'visible') continue + const bounds = parent.getBoundingClientRect() + top = Math.max(top, bounds.top) + bottom = Math.min(bottom, bounds.bottom) + } + return { above: rect.top - top, below: bottom - rect.bottom } +} + function Combobox({ value, onChange, @@ -34,6 +53,7 @@ function Combobox({ const listboxId = useId() const optionIdPrefix = useId() const [open, setOpen] = useState(false) + const [openUpward, setOpenUpward] = useState(false) const [search, setSearch] = useState('') const t = useT().ui const [highlighted, setHighlighted] = useState(0) @@ -58,6 +78,11 @@ function Combobox({ setSearch('') const selectedIndex = options.findIndex((o) => o.value === value) setHighlighted(Math.max(0, selectedIndex)) + if (ref.current) { + const listHeight = Math.min(LIST_MAX_HEIGHT, options.length * OPTION_HEIGHT + 10) + const { above, below } = roomAround(ref.current) + setOpenUpward(opensUpward(above, below, listHeight)) + } setOpen(true) } @@ -146,7 +171,7 @@ function Combobox({
    {filtered.length === 0 && (
  • diff --git a/GitWeb/src/renderer/src/components/TopBar.tsx b/GitWeb/src/renderer/src/components/TopBar.tsx index 0315b92..d08fcae 100644 --- a/GitWeb/src/renderer/src/components/TopBar.tsx +++ b/GitWeb/src/renderer/src/components/TopBar.tsx @@ -19,7 +19,8 @@ import { RefreshCw, Settings2, Trash2, - UploadCloud + UploadCloud, + Users } from 'lucide-react' import { basename } from '../lib/path' import DeleteBranchDialog from './DeleteBranchDialog' @@ -743,6 +744,7 @@ function TopBar(): JSX.Element { { id: 'history' as const, label: t.tabs.history, icon: Clock }, { id: 'stash' as const, label: t.tabs.stash, icon: Archive }, { id: 'pullRequests' as const, label: t.tabs.pullRequests, icon: GitPullRequest }, + { id: 'collaborators' as const, label: t.tabs.collaborators, icon: Users }, { id: 'dashboard' as const, label: t.tabs.dashboard, icon: LayoutDashboard }, { id: 'settings' as const, label: t.tabs.settings, icon: Settings2 } ] diff --git a/GitWeb/src/renderer/src/lib/dropdownPlacement.ts b/GitWeb/src/renderer/src/lib/dropdownPlacement.ts new file mode 100644 index 0000000..c3d1e71 --- /dev/null +++ b/GitWeb/src/renderer/src/lib/dropdownPlacement.ts @@ -0,0 +1,8 @@ +/** + * Opening upward is only safe when the whole list fits there: anything above a + * scroll container can't be scrolled to, while a list running past the bottom + * still can. So it stays below unless that would overflow and above has room. + */ +export function opensUpward(above: number, below: number, listHeight: number): boolean { + return below < listHeight && above >= listHeight +} diff --git a/GitWeb/src/renderer/src/store/collaboratorStore.ts b/GitWeb/src/renderer/src/store/collaboratorStore.ts new file mode 100644 index 0000000..bab1839 --- /dev/null +++ b/GitWeb/src/renderer/src/store/collaboratorStore.ts @@ -0,0 +1,212 @@ +import { create } from 'zustand' +import type { RemoteInfo } from '../lib/github' +import { + normalizeGitHubUsername, + type RepositoryCollaborator, + type RepositoryCollaborators, + type RepositoryRole +} from '../../../shared/github' +import { usePullRequestStore } from './pullRequestStore' +import { getT } from '../lib/i18n' + +interface CollaboratorStoreState { + /** The repository and account `data` belongs to, so the view never shows it to anyone else. */ + sessionKey: string | null + data: RepositoryCollaborators | null + isLoading: boolean + isSaving: boolean + /** Only when there is nothing to show; a failed refresh keeps the list and goes to the banner. */ + loadError: string | null + /** Why the username in the form can't be invited; shown next to the field. */ + fieldError: string | null + + load: () => Promise + /** Resolves true when the person was invited or added, so the view can clear the field. */ + add: (input: string, role?: RepositoryRole) => Promise + remove: (login: string) => Promise + cancelInvitation: (id: number, login: string) => Promise + changeRole: (login: string, role: RepositoryRole) => Promise + clearFieldError: () => void +} + +/** What one write needs: where it goes, and how to record its outcome once it lands. */ +interface WriteScope { + remote: RemoteInfo + patch: (change: (data: RepositoryCollaborators) => Partial) => void + reportSuccess: (message: string) => void +} + +export function sessionKeyOf(remote: RemoteInfo, login: string): string { + return `${remote.host}/${remote.owner}/${remote.repo}:${login}`.toLowerCase() +} + +function describeError(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +function sameLogin(a: string | null, b: string): boolean { + return a?.toLowerCase() === b.toLowerCase() +} + +function byLogin(a: RepositoryCollaborator, b: RepositoryCollaborator): number { + return a.login.localeCompare(b.login, undefined, { sensitivity: 'base' }) +} + +function currentSessionKey(): string | null { + const { remote, user } = usePullRequestStore.getState() + return remote && user ? sessionKeyOf(remote, user.login) : null +} + +// A slower answer must not overwrite what came after it: a newer load, or a +// write, whose result the older list doesn't know about yet. +let loadGeneration = 0 + +export const useCollaboratorStore = create((set, get) => { + /** + * Runs a write against the repository on screen. Outcomes go through the pull + * request store because that is what feeds the shell's GitHub banners. The + * user may have moved on by the time it lands, so the list is only touched if + * it is still this session's, and a banner about another repository names it. + */ + async function save(write: (scope: WriteScope) => Promise): Promise { + const { remote, user } = usePullRequestStore.getState() + if (!remote || !user || get().isSaving) return false + const key = sessionKeyOf(remote, user.login) + const describe = (message: string): string => + currentSessionKey() === key ? message : `${remote.owner}/${remote.repo}: ${message}` + + loadGeneration++ + set({ isSaving: true, isLoading: false }) + usePullRequestStore.setState({ error: null, successMessage: null }) + try { + await write({ + remote, + patch: (change) => + set((state) => (state.sessionKey === key && state.data ? { data: { ...state.data, ...change(state.data) } } : {})), + reportSuccess: (message) => usePullRequestStore.setState({ successMessage: describe(message) }) + }) + return true + } catch (err) { + usePullRequestStore.setState({ error: describe(describeError(err)) }) + return false + } finally { + set({ isSaving: false }) + } + } + + return { + sessionKey: null, + data: null, + isLoading: false, + isSaving: false, + loadError: null, + fieldError: null, + + load: async () => { + const { remote, user } = usePullRequestStore.getState() + if (!remote || !user) return + const key = sessionKeyOf(remote, user.login) + // Whatever a read returns now could predate the write in progress. + if (get().isSaving && get().sessionKey === key) return + const generation = ++loadGeneration + set((state) => + state.sessionKey === key + ? { isLoading: true, loadError: null } + : { sessionKey: key, data: null, isLoading: true, loadError: null, fieldError: null } + ) + try { + const data = await window.api.github.getRepositoryCollaborators(remote.host, remote.owner, remote.repo) + if (generation === loadGeneration) set({ data }) + } catch (err) { + if (generation !== loadGeneration) return + if (get().data) usePullRequestStore.setState({ error: describeError(err) }) + else set({ loadError: describeError(err) }) + } finally { + if (generation === loadGeneration) set({ isLoading: false }) + } + }, + + add: async (input, role) => { + const t = getT().collaborators + const login = normalizeGitHubUsername(input) + if (!login) { + set({ fieldError: input.trim().replace(/^@/, '').includes('@') ? t.emailNotAccepted : t.invalidUsername }) + return false + } + // Adding someone already here is a role change on GitHub's side, and the + // default role would quietly demote an admin. + const { data } = get() + if (data?.collaborators.some((c) => c.direct && sameLogin(c.login, login))) { + set({ fieldError: t.alreadyCollaborator(login) }) + return false + } + if (data?.invitations.some((i) => !i.expired && sameLogin(i.login, login))) { + set({ fieldError: t.alreadyInvited(login) }) + return false + } + + set({ fieldError: null }) + return save(async ({ remote, patch, reportSuccess }) => { + const result = await window.api.github.addCollaborator(remote.host, remote.owner, remote.repo, login, role) + if (result.kind === 'invited') { + // Takes the place of an expired invitation for the same person. + patch((current) => ({ + invitations: [result.invitation, ...current.invitations.filter((i) => i.id !== result.invitation.id && !sameLogin(i.login, login))] + })) + reportSuccess(t.invited(login)) + return + } + // Not read back: a list read right after the write can still show the old state. + patch((current) => { + const existing = current.collaborators.find((c) => sameLogin(c.login, login)) + const added: RepositoryCollaborator = { + login: existing?.login ?? login, + avatarUrl: existing?.avatarUrl ?? '', + role: role ?? existing?.role ?? 'write', + direct: true + } + return { collaborators: [...current.collaborators.filter((c) => c !== existing), added].sort(byLogin) } + }) + reportSuccess(t.added(login)) + }) + }, + + remove: async (login) => { + const t = getT().collaborators + // On an organization, a team or the org itself can still grant access this call can't see. + const isOrg = get().data?.ownerKind === 'org' + await save(async ({ remote, patch, reportSuccess }) => { + await window.api.github.removeCollaborator(remote.host, remote.owner, remote.repo, login) + patch((current) => ({ collaborators: current.collaborators.filter((c) => c.login !== login) })) + reportSuccess(isOrg ? t.directAccessRemoved(login) : t.removed(login)) + }) + }, + + cancelInvitation: async (id, login) => { + await save(async ({ remote, patch, reportSuccess }) => { + await window.api.github.cancelRepositoryInvitation(remote.host, remote.owner, remote.repo, id) + patch((current) => ({ invitations: current.invitations.filter((i) => i.id !== id) })) + reportSuccess(getT().collaborators.inviteCancelled(login)) + }) + }, + + changeRole: async (login, role) => { + await save(async ({ remote, patch, reportSuccess }) => { + const t = getT().collaborators + const result = await window.api.github.addCollaborator(remote.host, remote.owner, remote.repo, login, role) + if (result.kind === 'invited') { + patch((current) => ({ + collaborators: current.collaborators.filter((c) => c.login !== login), + invitations: [result.invitation, ...current.invitations] + })) + reportSuccess(t.invited(login)) + } else { + patch((current) => ({ collaborators: current.collaborators.map((c) => (c.login === login ? { ...c, role } : c)) })) + reportSuccess(t.roleChanged(login, t.roles[role])) + } + }) + }, + + clearFieldError: () => set({ fieldError: null }) + } +}) diff --git a/GitWeb/src/renderer/src/store/repoStore.ts b/GitWeb/src/renderer/src/store/repoStore.ts index 56df1e1..5edfb89 100644 --- a/GitWeb/src/renderer/src/store/repoStore.ts +++ b/GitWeb/src/renderer/src/store/repoStore.ts @@ -29,7 +29,7 @@ import { directPushBlock } from '../lib/branchProtection' import { isInsidePath, joinPath, pathKey } from '../lib/path' import { extractHunkPatch, parseFileDiff } from '../../../shared/patch' -type ViewName = 'changes' | 'history' | 'stash' | 'pullRequests' | 'dashboard' | 'settings' | 'about' +type ViewName = 'changes' | 'history' | 'stash' | 'pullRequests' | 'collaborators' | 'dashboard' | 'settings' | 'about' interface SelectedFile { path: string diff --git a/GitWeb/src/renderer/src/views/CollaboratorsView.tsx b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx new file mode 100644 index 0000000..2af43df --- /dev/null +++ b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx @@ -0,0 +1,386 @@ +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 ConfirmDialog from '../components/ConfirmDialog' +import Combobox from '../components/Combobox' +import { Avatar, LoginForm } from './PullRequestsView' +import { usePullRequestStore } from '../store/pullRequestStore' +import { sessionKeyOf, useCollaboratorStore } from '../store/collaboratorStore' +import { useLocale, useT } from '../lib/i18n' +import { relativeTime } from '../lib/relativeTime' +import type { GitHubUser, RemoteInfo } from '../lib/github' +import { canManageCollaborator, REPOSITORY_ROLES, type RepositoryRole } from '../../../shared/github' + +type PendingAction = + | { kind: 'remove'; login: string } + | { kind: 'cancel'; id: number; login: string } + | { kind: 'role'; login: string; role: RepositoryRole } + +const AVATAR_SIZE = 32 + +function isStandardRole(role: string): role is RepositoryRole { + return (REPOSITORY_ROLES as readonly string[]).includes(role) +} + +/** The same pill the pull request list uses to mark the current branch. */ +function NameBadge({ children }: { children: ReactNode }): JSX.Element { + return ( + + {children} + + ) +} + +/** Full-pane message for when there is no list to show. */ +function PaneMessage({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }): JSX.Element { + return ( +
    + {icon} +

    {title}

    + {children} +
    + ) +} + +function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHubUser }): JSX.Element { + const sessionKey = useCollaboratorStore((s) => s.sessionKey) + const storedData = useCollaboratorStore((s) => s.data) + const isLoading = useCollaboratorStore((s) => s.isLoading) + const isSaving = useCollaboratorStore((s) => s.isSaving) + const storedLoadError = useCollaboratorStore((s) => s.loadError) + const fieldError = useCollaboratorStore((s) => s.fieldError) + const load = useCollaboratorStore((s) => s.load) + const add = useCollaboratorStore((s) => s.add) + const remove = useCollaboratorStore((s) => s.remove) + const cancelInvitation = useCollaboratorStore((s) => s.cancelInvitation) + const changeRole = useCollaboratorStore((s) => s.changeRole) + const clearFieldError = useCollaboratorStore((s) => s.clearFieldError) + + const [username, setUsername] = useState('') + const [newRole, setNewRole] = useState('write') + const [pending, setPending] = useState(null) + const baseId = useId() + const t = useT().collaborators + const locale = useLocale() + + useEffect(() => { + void load() + }, [load]) + + // Between mounting and the first load the store still holds the previous repository or account. + const current = sessionKey === sessionKeyOf(remote, user.login) + const data = current ? storedData : null + const loadError = current ? storedLoadError : null + const shownFieldError = current ? fieldError : null + const repoName = `${remote.owner}/${remote.repo}` + const isOrg = data?.ownerKind === 'org' + + const roleLabel = (role: string): string => (isStandardRole(role) ? t.roles[role] : role) + const roleOptions = (extra?: string): { value: string; label: string }[] => [ + ...REPOSITORY_ROLES.map((value) => ({ value, label: t.roles[value] })), + // A custom role isn't in the list, and the field must not claim someone holds another one. + ...(extra && !isStandardRole(extra) ? [{ value: extra, label: extra }] : []) + ] + + async function handleAdd(event: FormEvent): Promise { + event.preventDefault() + if (await add(username, isOrg ? newRole : undefined)) setUsername('') + } + + function confirmPending(): void { + if (!pending) return + if (pending.kind === 'remove') void remove(pending.login) + else if (pending.kind === 'cancel') void cancelInvitation(pending.id, pending.login) + else void changeRole(pending.login, pending.role) + setPending(null) + } + + return ( +
    +
    +
    +
    +

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

    +

    {repoName}

    +
    + +
    + + {loadError ? ( + } title={t.loadFailedTitle}> +

    + {loadError} +

    + +
    + ) : !data ? ( +

    + + {t.loading} +

    + ) : !data.canView ? ( + } title={t.noAccessTitle}> +

    {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(e.target.value) + if (shownFieldError) clearFieldError() + }} + placeholder={t.usernamePlaceholder} + autoComplete="off" + spellCheck={false} + aria-invalid={shownFieldError ? true : undefined} + aria-describedby={`${baseId}-hint`} + className="field mt-1.5 h-[34px] w-full py-0" + /> +
    + {isOrg && ( +
    +

    {t.roleLabel}

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

    + {t.addHint} +

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

    {t.readOnly}

    +
    +
    + )} + + {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} + + +
      +
    • + ) + })} +
    +
    + )} + +
    +

    + {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 && ( +
      + {roleEditable && ( + { + if (value !== role && isStandardRole(value)) setPending({ kind: 'role', login, role: value }) + }} + options={roleOptions(role)} + ariaLabel={t.roleAria(login)} + disabled={isSaving} + className="w-36" + /> + )} + +
      + )} +
    • + ) + })} +
    + )} +
    + + )} +
    + + setPending(null)} + /> + setPending(null)} + /> + setPending(null)} + /> +
    + ) +} + +function CollaboratorsView(): JSX.Element { + const remote = usePullRequestStore((s) => s.remote) + const user = usePullRequestStore((s) => s.user) + const t = useT().collaborators + + if (!remote) { + return ( +
    +

    {t.title}

    + +

    {t.noRemote}

    +
    + ) + } + + if (!user) { + return ( +
    +

    {t.title}

    + +
    + ) + } + + // Keyed so a half-typed username or an open dialog never carries over to another repository. + return +} + +export default CollaboratorsView diff --git a/GitWeb/src/renderer/src/views/PullRequestsView.tsx b/GitWeb/src/renderer/src/views/PullRequestsView.tsx index 3aa3cf8..5720603 100644 --- a/GitWeb/src/renderer/src/views/PullRequestsView.tsx +++ b/GitWeb/src/renderer/src/views/PullRequestsView.tsx @@ -157,7 +157,7 @@ function MergeMethodHint({ method }: { method: MergeMethod }): JSX.Element { ) } -function LoginForm(): JSX.Element { +export function LoginForm({ hint }: { hint?: string }): JSX.Element { const remote = usePullRequestStore((s) => s.remote) const isBusy = usePullRequestStore((s) => s.isBusy) const loginWithToken = usePullRequestStore((s) => s.loginWithToken) @@ -173,7 +173,7 @@ function LoginForm(): JSX.Element {

    {t.remoteDetected} {remote?.owner}/{remote?.repo} {t.remoteOn}{' '} - {remote?.host}. {t.loginHint} + {remote?.host}. {hint ?? t.loginHint}