feat: add repo and PR management features with ws client

This commit is contained in:
zhenyi
2026-06-12 17:20:41 +08:00
parent d7c4bc7c8e
commit 48fb9ce3f8
69 changed files with 3179 additions and 219 deletions
+88
View File
@@ -1035,6 +1035,48 @@
}
}
},
"/api/v1/auth/ws-token": {
"post": {
"tags": [
"Auth"
],
"summary": "Issue a short-lived WebSocket token",
"description": "Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. The token is signed by the appks signing key and can be verified by imks either locally (via cached signing keys) or via RPC. The returned token should be passed as `{ token: <value> }` in the Socket.IO CONNECT auth packet. Requires an authenticated session.",
"operationId": "authWsToken",
"responses": {
"200": {
"description": "Token issued successfully.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponse_WsTokenResponse"
}
}
}
},
"401": {
"description": "The current session is unauthenticated or the login state has expired.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
},
"500": {
"description": "Token issuance or Redis write failed.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorResponse"
}
}
}
}
}
}
},
"/api/v1/im/workspaces/{workspace_name}/categories": {
"get": {
"tags": [
@@ -44098,6 +44140,33 @@
}
}
},
"ApiResponse_WsTokenResponse": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"description": "Response payload for `POST /auth/ws-token`.",
"required": [
"token",
"expires_at"
],
"properties": {
"expires_at": {
"type": "integer",
"format": "int64",
"description": "Unix timestamp (seconds) when the token expires."
},
"token": {
"type": "string",
"description": "Short-lived JWT prefixed with \"Bearer \" for use in the Socket.IO CONNECT auth packet."
}
}
}
}
},
"ApiResponse_bool": {
"type": "object",
"required": [
@@ -55495,6 +55564,25 @@
"format": "uuid"
}
}
},
"WsTokenResponse": {
"type": "object",
"description": "Response payload for `POST /auth/ws-token`.",
"required": [
"token",
"expires_at"
],
"properties": {
"expires_at": {
"type": "integer",
"format": "int64",
"description": "Unix timestamp (seconds) when the token expires."
},
"token": {
"type": "string",
"description": "Short-lived JWT prefixed with \"Bearer \" for use in the Socket.IO CONNECT auth packet."
}
}
}
}
},
+4
View File
@@ -214,6 +214,7 @@ export type { ApiResponse_WorkspacePendingApproval } from './models/ApiResponse_
export type { ApiResponse_WorkspaceSettings } from './models/ApiResponse_WorkspaceSettings';
export type { ApiResponse_WorkspaceStats } from './models/ApiResponse_WorkspaceStats';
export type { ApiResponse_WorkspaceWebhook } from './models/ApiResponse_WorkspaceWebhook';
export type { ApiResponse_WsTokenResponse } from './models/ApiResponse_WsTokenResponse';
export type { AvatarData } from './models/AvatarData';
export type { BlameHunk } from './models/BlameHunk';
export type { BlameLine } from './models/BlameLine';
@@ -496,6 +497,7 @@ export type { WorkspacePendingApproval } from './models/WorkspacePendingApproval
export type { WorkspaceSettings } from './models/WorkspaceSettings';
export type { WorkspaceStats } from './models/WorkspaceStats';
export type { WorkspaceWebhook } from './models/WorkspaceWebhook';
export type { WsTokenResponse } from './models/WsTokenResponse';
export { $AcceptInvitationParams } from './schemas/$AcceptInvitationParams';
export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest';
@@ -704,6 +706,7 @@ export { $ApiResponse_WorkspacePendingApproval } from './schemas/$ApiResponse_Wo
export { $ApiResponse_WorkspaceSettings } from './schemas/$ApiResponse_WorkspaceSettings';
export { $ApiResponse_WorkspaceStats } from './schemas/$ApiResponse_WorkspaceStats';
export { $ApiResponse_WorkspaceWebhook } from './schemas/$ApiResponse_WorkspaceWebhook';
export { $ApiResponse_WsTokenResponse } from './schemas/$ApiResponse_WsTokenResponse';
export { $AvatarData } from './schemas/$AvatarData';
export { $BlameHunk } from './schemas/$BlameHunk';
export { $BlameLine } from './schemas/$BlameLine';
@@ -986,6 +989,7 @@ export { $WorkspacePendingApproval } from './schemas/$WorkspacePendingApproval';
export { $WorkspaceSettings } from './schemas/$WorkspaceSettings';
export { $WorkspaceStats } from './schemas/$WorkspaceStats';
export { $WorkspaceWebhook } from './schemas/$WorkspaceWebhook';
export { $WsTokenResponse } from './schemas/$WsTokenResponse';
export { AuthService } from './services/AuthService';
export { GitService } from './services/GitService';
@@ -0,0 +1,20 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResponse_WsTokenResponse = {
/**
* Response payload for `POST /auth/ws-token`.
*/
data: {
/**
* Unix timestamp (seconds) when the token expires.
*/
expires_at: number;
/**
* Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.
*/
token: string;
};
};
+18
View File
@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response payload for `POST /auth/ws-token`.
*/
export type WsTokenResponse = {
/**
* Unix timestamp (seconds) when the token expires.
*/
expires_at: number;
/**
* Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.
*/
token: string;
};
@@ -0,0 +1,25 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ApiResponse_WsTokenResponse = {
properties: {
data: {
description: `Response payload for \`POST /auth/ws-token\`.`,
properties: {
expires_at: {
type: 'number',
description: `Unix timestamp (seconds) when the token expires.`,
isRequired: true,
format: 'int64',
},
token: {
type: 'string',
description: `Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.`,
isRequired: true,
},
},
isRequired: true,
},
},
} as const;
+20
View File
@@ -0,0 +1,20 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $WsTokenResponse = {
description: `Response payload for \`POST /auth/ws-token\`.`,
properties: {
expires_at: {
type: 'number',
description: `Unix timestamp (seconds) when the token expires.`,
isRequired: true,
format: 'int64',
},
token: {
type: 'string',
description: `Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.`,
isRequired: true,
},
},
} as const;
+17
View File
@@ -12,6 +12,7 @@ import type { ApiResponse_Regenerate2FABackupCodesResponse } from '../models/Api
import type { ApiResponse_RegisterEmailCodeResponse } from '../models/ApiResponse_RegisterEmailCodeResponse';
import type { ApiResponse_RegisterResponse } from '../models/ApiResponse_RegisterResponse';
import type { ApiResponse_RsaResponse } from '../models/ApiResponse_RsaResponse';
import type { ApiResponse_WsTokenResponse } from '../models/ApiResponse_WsTokenResponse';
import type { ChangePasswordParams } from '../models/ChangePasswordParams';
import type { Disable2FAParams } from '../models/Disable2FAParams';
import type { EmailChangeRequest } from '../models/EmailChangeRequest';
@@ -450,4 +451,20 @@ export class AuthService {
},
});
}
/**
* Issue a short-lived WebSocket token
* Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. The token is signed by the appks signing key and can be verified by imks either locally (via cached signing keys) or via RPC. The returned token should be passed as `{ token: <value> }` in the Socket.IO CONNECT auth packet. Requires an authenticated session.
* @returns ApiResponse_WsTokenResponse Token issued successfully.
* @throws ApiError
*/
public static authWsToken(): CancelablePromise<ApiResponse_WsTokenResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/ws-token',
errors: {
401: `The current session is unauthenticated or the login state has expired.`,
500: `Token issuance or Redis write failed.`,
},
});
}
}
+31
View File
@@ -34,46 +34,74 @@ export { useAssignPr } from './pull-request/useAssignPr';
export { useAssignPrLabel } from './pull-request/useAssignPrLabel';
export { useClosePr } from './pull-request/useClosePr';
export { useCreatePr } from './pull-request/useCreatePr';
export { useCreateReview } from './pull-request/useCreateReview';
export { useMergePr } from './pull-request/useMergePr';
export { usePrAssignees } from './pull-request/usePrAssignees';
export { usePrCheckRuns } from './pull-request/usePrCheckRuns';
export { usePrCommits } from './pull-request/usePrCommits';
export { usePrDetail } from './pull-request/usePrDetail';
export { usePrEvents } from './pull-request/usePrEvents';
export { usePrFiles } from './pull-request/usePrFiles';
export { usePrLabelRelations } from './pull-request/usePrLabelRelations';
export { usePrLabels } from './pull-request/usePrLabels';
export { usePrList } from './pull-request/usePrList';
export { usePrReactions } from './pull-request/usePrReactions';
export { usePrReviews } from './pull-request/usePrReviews';
export { usePrStatus } from './pull-request/usePrStatus';
export { useRemovePrReaction } from './pull-request/useRemovePrReaction';
export { useReopenPr } from './pull-request/useReopenPr';
export { useSubmitReview } from './pull-request/useSubmitReview';
export { useUnassignPr } from './pull-request/useUnassignPr';
export { useUnassignPrLabel } from './pull-request/useUnassignPrLabel';
export { useUpdatePr } from './pull-request/useUpdatePr';
// repo
export { useAddDeployKey } from './repo/useAddDeployKey';
export { useAddRepoMember } from './repo/useAddRepoMember';
export { useArchiveRepo } from './repo/useArchiveRepo';
export { useCreateBranch } from './repo/useCreateBranch';
export { useCreateProtectionRule } from './repo/useCreateProtectionRule';
export { useCreateRelease } from './repo/useCreateRelease';
export { useCreateRepo } from './repo/useCreateRepo';
export { useCreateTag } from './repo/useCreateTag';
export { useCreateWebhook } from './repo/useCreateWebhook';
export { useDeleteBranch } from './repo/useDeleteBranch';
export { useDeleteDeployKey } from './repo/useDeleteDeployKey';
export { useDeleteProtectionRule } from './repo/useDeleteProtectionRule';
export { useDeleteRelease } from './repo/useDeleteRelease';
export { useDeleteRepo } from './repo/useDeleteRepo';
export { useDeleteTag } from './repo/useDeleteTag';
export { useDeleteWebhook } from './repo/useDeleteWebhook';
export { useForkRepo } from './repo/useForkRepo';
export { useGitBlame } from './repo/useGitBlame';
export { useGitBlob } from './repo/useGitBlob';
export { useGitCommit } from './repo/useGitCommit';
export { useGitCommits } from './repo/useGitCommits';
export { useGitDiff } from './repo/useGitDiff';
export { useGitDiffStats } from './repo/useGitDiffStats';
export { useGitTree } from './repo/useGitTree';
export { useRemoveRepoMember } from './repo/useRemoveRepoMember';
export { useRepo } from './repo/useRepo';
export { useRepoBranches } from './repo/useRepoBranches';
export { useRepoDeployKeys } from './repo/useRepoDeployKeys';
export { useRepoForks } from './repo/useRepoForks';
export { useRepoInvitations } from './repo/useRepoInvitations';
export { useRepoMembers } from './repo/useRepoMembers';
export { useRepoProtectionRules } from './repo/useRepoProtectionRules';
export { useRepoPulls } from './repo/useRepoPulls';
export { useRepoRelease } from './repo/useRepoRelease';
export { useRepoReleases } from './repo/useRepoReleases';
export { useRepoStars } from './repo/useRepoStars';
export { useRepoStats } from './repo/useRepoStats';
export { useRepoTags } from './repo/useRepoTags';
export { useRepoWatchers } from './repo/useRepoWatchers';
export { useRepoWebhooks } from './repo/useRepoWebhooks';
export { useSetDefaultBranch } from './repo/useSetDefaultBranch';
export { useStarRepo, useUnstarRepo } from './repo/useStarRepo';
export { useTransferRepo } from './repo/useTransferRepo';
export { useUpdateRelease } from './repo/useUpdateRelease';
export { useUpdateRepo } from './repo/useUpdateRepo';
export { useUpdateRepoMemberRole } from './repo/useUpdateRepoMemberRole';
export { useWatchRepo, useUnwatchRepo } from './repo/useWatchRepo';
// user
export { useAccessTokens } from './user/useAccessTokens';
export { useCurrentUser } from './user/useCurrentUser';
@@ -89,6 +117,9 @@ export { useUserNotifications } from './user/useUserNotifications';
export { useUserProfile } from './user/useUserProfile';
export { useUserSessions } from './user/useUserSessions';
// wiki
export { useCreateWikiPage } from './wiki/useCreateWikiPage';
export { useDeleteWikiPage } from './wiki/useDeleteWikiPage';
export { useUpdateWikiPage } from './wiki/useUpdateWikiPage';
export { useWikiPage } from './wiki/useWikiPage';
export { useWikiPages } from './wiki/useWikiPages';
export { useWikiRevisions } from './wiki/useWikiRevisions';
+25
View File
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { PullRequestsService } from '@/client/services/PullRequestsService';
import type { CreateReviewParams } from '@/client/models/CreateReviewParams';
export function useCreateReview() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ number, ...params }: CreateReviewParams & { number: number }) =>
PullRequestsService.prCreateReview({
workspaceName,
repoName,
number,
requestBody: params,
}),
onSuccess: (_, vars) => {
qc.invalidateQueries({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', vars.number],
});
},
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { PullRequestsService } from '@/client/services/PullRequestsService';
export function usePrCheckRuns(
workspaceName: string | null,
repoName: string | null,
number: number | null,
) {
return useQuery({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', number, 'check-runs'],
queryFn: () => {
if (!workspaceName || !repoName || number === null) throw new Error('Missing params');
return PullRequestsService.prListCheckRuns({ workspaceName, repoName, number }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && number !== null,
staleTime: 1000 * 60,
});
}
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { PullRequestsService } from '@/client/services/PullRequestsService';
export function usePrCommits(
workspaceName: string | null,
repoName: string | null,
number: number | null,
limit = 50,
offset = 0,
) {
return useQuery({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', number, 'commits', { limit, offset }],
queryFn: () => {
if (!workspaceName || !repoName || number === null) throw new Error('Missing params');
return PullRequestsService.prListCommits({
workspaceName,
repoName,
number,
limit,
offset,
}).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName && number !== null,
staleTime: 1000 * 60,
});
}
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { PullRequestsService } from '@/client/services/PullRequestsService';
export function usePrEvents(
workspaceName: string | null,
repoName: string | null,
number: number | null,
limit = 50,
offset = 0,
) {
return useQuery({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', number, 'events', { limit, offset }],
queryFn: () => {
if (!workspaceName || !repoName || number === null) throw new Error('Missing params');
return PullRequestsService.prListEvents({
workspaceName,
repoName,
number,
limit,
offset,
}).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName && number !== null,
staleTime: 1000 * 60,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { PullRequestsService } from '@/client/services/PullRequestsService';
export function usePrStatus(
workspaceName: string | null,
repoName: string | null,
number: number | null,
) {
return useQuery({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', number, 'status'],
queryFn: () => {
if (!workspaceName || !repoName || number === null) throw new Error('Missing params');
return PullRequestsService.prGetStatus({ workspaceName, repoName, number }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && number !== null,
staleTime: 1000 * 60,
});
}
+30
View File
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { PullRequestsService } from '@/client/services/PullRequestsService';
import type { SubmitReviewParams } from '@/client/models/SubmitReviewParams';
export function useSubmitReview() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: ({
number,
reviewId,
...params
}: SubmitReviewParams & { number: number; reviewId: string }) =>
PullRequestsService.prSubmitReview({
workspaceName,
repoName,
number,
reviewId,
requestBody: params,
}),
onSuccess: (_, vars) => {
qc.invalidateQueries({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'prs', vars.number],
});
},
});
}
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import type { AddDeployKeyParams } from '@/client/models/AddDeployKeyParams';
import { ReposService } from '@/client/services/ReposService';
export function useAddDeployKey() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: (params: AddDeployKeyParams) =>
ReposService.repoAddDeployKey({ workspaceName, repoName, requestBody: params }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'deploy-keys'] });
},
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
import type { Role } from '@/client/models/Role';
export function useAddRepoMember() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role?: Role }) =>
ReposService.repoAddMember({
workspaceName,
repoName,
requestBody: { user_id: userId, role },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'members'] });
},
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function useCreateBranch() {
const qc = useQueryClient();
return useMutation({
mutationFn: (params: { name: string; commit_sha: string }) =>
mutationFn: (params: { branch_name: string; start_point: string }) =>
ReposService.repoCreateBranch({ workspaceName, repoName, requestBody: params }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import type { CreateReleaseParams } from '@/client/models/CreateReleaseParams';
import { ReposService } from '@/client/services/ReposService';
export function useCreateRelease() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: (params: CreateReleaseParams) =>
ReposService.repoCreateRelease({ workspaceName, repoName, requestBody: params }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'releases'] });
},
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function useCreateTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: (params: { name: string; target_commit_sha: string; message?: string }) =>
mutationFn: (params: { tag_name: string; target: string; message?: string }) =>
ReposService.repoCreateTag({ workspaceName, repoName, requestBody: params }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
+2 -2
View File
@@ -8,8 +8,8 @@ export function useDeleteBranch() {
const qc = useQueryClient();
return useMutation({
mutationFn: (branchId: string) =>
ReposService.repoDeleteBranch({ workspaceName, repoName, branchId }),
mutationFn: (branchName: string) =>
ReposService.repoDeleteBranch({ workspaceName, repoName, branchName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
},
+17
View File
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
export function useDeleteDeployKey() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: (keyId: string) =>
ReposService.repoDeleteDeployKey({ workspaceName, repoName, keyId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'deploy-keys'] });
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
export function useDeleteRelease() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: (releaseId: string) =>
ReposService.repoDeleteRelease({ workspaceName, repoName, releaseId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'releases'] });
},
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function useDeleteTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: (tagId: string) => ReposService.repoDeleteTag({ workspaceName, repoName, tagId }),
mutationFn: (tagName: string) => ReposService.repoDeleteTag({ workspaceName, repoName, tagName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
},
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitBlame(
workspaceName: string | null,
repoName: string | null,
revision: string | null,
path: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'blame', { revision, path }],
queryFn: () => {
if (!workspaceName || !repoName || !revision || !path) throw new Error('Missing params');
return GitService.gitBlame({ workspaceName, repoName, revision, path }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && !!revision && !!path,
staleTime: 1000 * 60 * 2,
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitBlob(
workspaceName: string | null,
repoName: string | null,
revision: string | null,
path: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'blob', { revision, path }],
queryFn: () => {
if (!workspaceName || !repoName || !revision || !path) throw new Error('Missing params');
return GitService.gitGetBlob({ workspaceName, repoName, revision, path }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && !!revision && !!path,
staleTime: 1000 * 60 * 2,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitCommit(
workspaceName: string | null,
repoName: string | null,
revision: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'commit', revision],
queryFn: () => {
if (!workspaceName || !repoName || !revision) throw new Error('Missing params');
return GitService.gitGetCommit({ workspaceName, repoName, revision }).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName && !!revision,
staleTime: 1000 * 60 * 5,
});
}
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitCommits(
workspaceName: string | null,
repoName: string | null,
revision?: string,
path?: string,
pageSize = 30,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'commits', { revision, path, pageSize }],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return GitService.gitListCommits({
workspaceName,
repoName,
revision: revision ?? 'HEAD',
path: path ?? undefined,
pageSize,
}).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName,
staleTime: 1000 * 60,
});
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitDiff(
workspaceName: string | null,
repoName: string | null,
base: string | null,
head: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'diff', { base, head }],
queryFn: () => {
if (!workspaceName || !repoName || !base || !head) throw new Error('Missing params');
return GitService.gitDiff({ workspaceName, repoName, base, head }).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName && !!base && !!head,
staleTime: 1000 * 60 * 5,
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitDiffStats(
workspaceName: string | null,
repoName: string | null,
base: string | null,
head: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'diff-stats', { base, head }],
queryFn: () => {
if (!workspaceName || !repoName || !base || !head) throw new Error('Missing params');
return GitService.gitDiffStats({ workspaceName, repoName, base, head }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && !!base && !!head,
staleTime: 1000 * 60 * 5,
});
}
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { GitService } from '@/client/services/GitService';
export function useGitTree(
workspaceName: string | null,
repoName: string | null,
revision?: string,
path?: string,
recursive?: boolean,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'git', 'tree', { revision, path, recursive }],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return GitService.gitListTree({
workspaceName,
repoName,
revision: revision ?? 'HEAD',
path: path ?? undefined,
recursive: recursive ?? undefined,
}).then((r) => r.data);
},
enabled: !!workspaceName && !!repoName,
staleTime: 1000 * 60,
});
}
+17
View File
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
export function useRemoveRepoMember() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: (memberId: string) =>
ReposService.repoRemoveMember({ workspaceName, repoName, memberId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'members'] });
},
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function useRepoBranches(workspaceName: string | null, repoName: string |
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListBranches({ workspaceName, repoName, limit: 100 }).then(
(r) => r.data,
(r) => r.data.branches,
);
},
enabled: !!workspaceName && !!repoName,
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { ReposService } from '@/client/services/ReposService';
export function useRepoDeployKeys(workspaceName: string | null, repoName: string | null) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'deploy-keys'],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListDeployKeys({ workspaceName, repoName }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { ReposService } from '@/client/services/ReposService';
export function useRepoRelease(
workspaceName: string | null,
repoName: string | null,
releaseId: string | null,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'releases', releaseId],
queryFn: () => {
if (!workspaceName || !repoName || !releaseId) throw new Error('Missing params');
return ReposService.repoGetRelease({ workspaceName, repoName, releaseId }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName && !!releaseId,
staleTime: 1000 * 60 * 2,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { ReposService } from '@/client/services/ReposService';
export function useRepoReleases(
workspaceName: string | null,
repoName: string | null,
limit = 20,
offset = 0,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'releases', { limit, offset }],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListReleases({ workspaceName, repoName, limit, offset }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { ReposService } from '@/client/services/ReposService';
export function useRepoStars(
workspaceName: string | null,
repoName: string | null,
limit = 50,
offset = 0,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'stars', { limit, offset }],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListStargazers({ workspaceName, repoName, limit, offset }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName,
});
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function useRepoTags(
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListTags({ workspaceName, repoName, offset, limit }).then(
(r) => r.data,
(r) => r.data.tags,
);
},
enabled: !!workspaceName && !!repoName,
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { ReposService } from '@/client/services/ReposService';
export function useRepoWatchers(
workspaceName: string | null,
repoName: string | null,
limit = 50,
offset = 0,
) {
return useQuery({
queryKey: ['repo', workspaceName, repoName, 'watchers', { limit, offset }],
queryFn: () => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return ReposService.repoListWatchers({ workspaceName, repoName, limit, offset }).then(
(r) => r.data,
);
},
enabled: !!workspaceName && !!repoName,
});
}
+2 -2
View File
@@ -8,8 +8,8 @@ export function useSetDefaultBranch() {
const qc = useQueryClient();
return useMutation({
mutationFn: (branchId: string) =>
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchId }),
mutationFn: (branchName: string) =>
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
+30
View File
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
export function useStarRepo() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: () => ReposService.repoStar({ workspaceName, repoName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stars'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stats'] });
},
});
}
export function useUnstarRepo() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: () => ReposService.repoUnstar({ workspaceName, repoName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stars'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stats'] });
},
});
}
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import type { UpdateReleaseParams } from '@/client/models/UpdateReleaseParams';
import { ReposService } from '@/client/services/ReposService';
export function useUpdateRelease() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ releaseId, ...params }: UpdateReleaseParams & { releaseId: string }) =>
ReposService.repoUpdateRelease({ workspaceName, repoName, releaseId, requestBody: params }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'releases'] });
},
});
}
+23
View File
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import type { Role } from '@/client/models/Role';
import { ReposService } from '@/client/services/ReposService';
export function useUpdateRepoMemberRole() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ memberId, role }: { memberId: string; role: Role }) =>
ReposService.repoUpdateMemberRole({
workspaceName,
repoName,
memberId,
requestBody: { role },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'members'] });
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
export function useWatchRepo() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
ReposService.repoWatch({ workspaceName, repoName, requestBody: {} }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'watchers'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stats'] });
},
});
}
export function useUnwatchRepo() {
const { workspaceName = '', repoName = '' } = useParams();
const qc = useQueryClient();
return useMutation({
mutationFn: () => ReposService.repoUnwatch({ workspaceName, repoName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'watchers'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'stats'] });
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { WikiService } from '@/client/services/WikiService';
import type { CreateWikiPageParams } from '@/client/models/CreateWikiPageParams';
export function useCreateWikiPage(workspaceName: string | null, repoName: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: (params: CreateWikiPageParams) => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return WikiService.wikiCreatePage({ workspaceName, repoName, requestBody: params });
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'wiki'],
});
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { WikiService } from '@/client/services/WikiService';
export function useDeleteWikiPage(workspaceName: string | null, repoName: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: (slug: string) => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return WikiService.wikiDeletePage({ workspaceName, repoName, slug });
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'wiki'],
});
},
});
}
+25
View File
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { WikiService } from '@/client/services/WikiService';
import type { UpdateWikiPageParams } from '@/client/models/UpdateWikiPageParams';
export function useUpdateWikiPage(workspaceName: string | null, repoName: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ slug, ...params }: UpdateWikiPageParams & { slug: string }) => {
if (!workspaceName || !repoName) throw new Error('Missing params');
return WikiService.wikiUpdatePage({
workspaceName,
repoName,
slug,
requestBody: params,
});
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['workspace', workspaceName, 'repos', repoName, 'wiki'],
});
},
});
}
+5 -1
View File
@@ -5,6 +5,8 @@ import { RouterProvider } from 'react-router-dom';
import { toast } from 'sonner';
import { UserProvider } from '@/contexts/UserContext';
import { SocketProvider } from '@/socket';
import { env } from '@/lib/env';
import { router } from './routes';
import './index.css';
@@ -32,7 +34,9 @@ createRoot(doc).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<UserProvider>
<RouterProvider router={router} />
<SocketProvider imksUrl={env.IMKS_URL}>
<RouterProvider router={router} />
</SocketProvider>
</UserProvider>
</QueryClientProvider>
</StrictMode>,
+1
View File
@@ -1,5 +1,6 @@
export const env = {
API_BASE_URL: import.meta.env.VITE_API_BASE_URL ?? '/api',
IMKS_URL: import.meta.env.VITE_IMKS_URL ?? 'http://localhost:50048',
DEV: import.meta.env.DEV,
MODE: import.meta.env.MODE,
} as const;
+214
View File
@@ -0,0 +1,214 @@
import { ArrowLeft, Code, Copy, Download, GitCommit } from 'lucide-react';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { useGitBlame } from '@/hooks/repo/useGitBlame';
import { useGitBlob } from '@/hooks/repo/useGitBlob';
import { useRepo } from '@/hooks/repo/useRepo';
export function RepoBlobPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [searchParams] = useSearchParams();
const branch = searchParams.get('branch') || '';
const filePath = searchParams.get('blob') || '';
const { data: repo } = useRepo(workspaceName, repoName);
const { data: blob, isLoading } = useGitBlob(
workspaceName,
repoName,
branch || (repo?.default_branch ?? null),
filePath || null,
);
const [view, setView] = useState<'code' | 'blame'>('code');
const effectiveBranch = branch || repo?.default_branch || '';
const fileName = filePath.split('/').pop() || '';
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
);
}
if (!blob) {
return (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground">File not found</p>
<Link
to={`/${workspaceName}/repos/${repoName}?branch=${effectiveBranch}`}
className="mt-2 inline-block text-[13px] text-primary hover:underline"
>
Back to code
</Link>
</div>
);
}
const content = blob.binary ? null : new TextDecoder().decode(new Uint8Array(blob.data));
const lines = content ? content.split('\n') : [];
const handleCopy = () => {
if (content) navigator.clipboard.writeText(content);
};
const handleDownload = () => {
if (!content) return;
const blobObj = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blobObj);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Link
to={`/${workspaceName}/repos/${repoName}?branch=${effectiveBranch}`}
className="flex items-center gap-1 text-[13px] text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
Back
</Link>
<span className="text-[13px] text-muted-foreground">/</span>
<span className="text-[13px] font-medium text-foreground">{filePath}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>{lines.length} lines</span>
<span>·</span>
<span>{formatSize(blob.size)}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant={view === 'code' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('code')}
>
<Code className="size-3.5" />
Code
</Button>
<Button
variant={view === 'blame' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('blame')}
>
<GitCommit className="size-3.5" />
Blame
</Button>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="size-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="size-3.5" />
</Button>
</div>
</div>
{blob.binary ? (
<div className="rounded-lg border border-border p-8 text-center">
<p className="text-[13px] text-muted-foreground">Binary file not shown</p>
</div>
) : view === 'code' ? (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="overflow-x-auto">
<table className="w-full">
<tbody>
{lines.map((line, idx) => (
<tr key={idx} className="border-b border-border/50 last:border-b-0">
<td className="select-none px-3 py-0.5 text-right text-[11px] text-muted-foreground/60">
{idx + 1}
</td>
<td className="px-3 py-0.5">
<pre className="text-[13px] text-foreground">{line}</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<BlameView
workspaceName={workspaceName}
repoName={repoName}
branch={effectiveBranch}
path={filePath}
/>
)}
</div>
);
}
function BlameView({
workspaceName,
repoName,
branch,
path,
}: {
workspaceName: string;
repoName: string;
branch: string;
path: string;
}) {
const { data: blame, isLoading } = useGitBlame(workspaceName, repoName, branch, path);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
);
}
if (!blame) {
return <div className="py-12 text-center text-[13px] text-muted-foreground">No blame data</div>;
}
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="divide-y divide-border">
{blame.hunks.map((hunk, idx) => {
const commitMsg = hunk.commit?.subject || 'Unknown';
const commitSha = hunk.commit?.abbreviated_oid || '';
const lines = hunk.lines || [];
return (
<div key={idx}>
<div className="border-b border-border/50 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground">
<code className="mr-2">{commitSha}</code>
{commitMsg}
</div>
{lines.map((line, lineIdx) => (
<div key={lineIdx} className="flex border-b border-border/30 last:border-b-0">
<div className="w-12 shrink-0 select-none px-2 py-0.5 text-right text-[11px] text-muted-foreground/60">
{line.final_line}
</div>
<pre className="flex-1 px-3 py-0.5 text-[13px] text-foreground">
{new TextDecoder().decode(new Uint8Array(line.content))}
</pre>
</div>
))}
</div>
);
})}
</div>
</div>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+31 -33
View File
@@ -1,5 +1,5 @@
import { type UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query';
import { GitBranch, Plus, Search, Shield, Star, Trash2 } from 'lucide-react';
import { GitBranch, Plus, Search, Star, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { ReposService } from '@/client/services/ReposService';
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
import { cn, timeAgo } from '@/lib/utils';
import { cn } from '@/lib/utils';
export function RepoBranchesPage() {
const { workspaceName = '', repoName = '' } = useParams();
@@ -24,7 +24,7 @@ export function RepoBranchesPage() {
ReposService.repoCreateBranch({
workspaceName,
repoName,
requestBody: { name: newName, commit_sha: newSource || 'HEAD' },
requestBody: { branch_name: newName, start_point: newSource || 'HEAD' },
}),
onSuccess: () => {
setNewName('');
@@ -35,27 +35,28 @@ export function RepoBranchesPage() {
});
const deleteBranch = useMutation({
mutationFn: (branchId: string) =>
ReposService.repoDeleteBranch({ workspaceName, repoName, branchId }),
mutationFn: (branchName: string) =>
ReposService.repoDeleteBranch({ workspaceName, repoName, branchName }),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] }),
});
const setDefault = useMutation({
mutationFn: (branchId: string) =>
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchId }),
mutationFn: (branchName: string) =>
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
},
});
const list = branches ?? [];
const filtered = search
? (branches ?? []).filter((b) => b.name.toLowerCase().includes(search.toLowerCase()))
: (branches ?? []);
? list.filter((b) => b.name.toLowerCase().includes(search.toLowerCase()))
: list;
const defaultBranches = filtered.filter((b) => b.default_branch);
const otherBranches = filtered.filter((b) => !b.default_branch);
const defaultBranches = filtered.filter((b) => b.is_default);
const otherBranches = filtered.filter((b) => !b.is_default);
return (
<div className="space-y-4">
@@ -85,7 +86,7 @@ export function RepoBranchesPage() {
/>
<Input
className="h-8 w-40 text-[13px]"
placeholder="Source (commit SHA)"
placeholder="Source (branch/commit)"
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
/>
@@ -111,7 +112,7 @@ export function RepoBranchesPage() {
<div>
{defaultBranches.map((b) => (
<BranchRow
key={b.id}
key={b.name}
branch={b}
isDefault
onDelete={deleteBranch}
@@ -126,7 +127,7 @@ export function RepoBranchesPage() {
</div>
)}
{otherBranches.map((b) => (
<BranchRow key={b.id} branch={b} onDelete={deleteBranch} onSetDefault={setDefault} />
<BranchRow key={b.name} branch={b} onDelete={deleteBranch} onSetDefault={setDefault} />
))}
</div>
) : (
@@ -144,12 +145,10 @@ export function RepoBranchesPage() {
interface BranchRowProps {
branch: {
id: string;
name: string;
commit_sha: string;
default_branch: boolean;
protected: boolean;
last_push_at?: string | null;
commit?: { abbreviated_oid?: string } | null;
is_default?: boolean;
is_merged?: boolean;
};
isDefault?: boolean;
onDelete: UseMutationResult<unknown, unknown, string>;
@@ -157,37 +156,36 @@ interface BranchRowProps {
}
function BranchRow({ branch, isDefault, onDelete, onSetDefault }: BranchRowProps) {
const commitSha = branch.commit?.abbreviated_oid ?? '';
const isDef = isDefault || branch.is_default;
return (
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40">
<GitBranch
className={cn('size-4 shrink-0', isDefault ? 'text-primary' : 'text-muted-foreground')}
className={cn('size-4 shrink-0', isDef ? 'text-primary' : 'text-muted-foreground')}
/>
<span className="text-[13px] font-semibold text-foreground">{branch.name}</span>
{isDefault && (
{isDef && (
<span className="inline-flex items-center gap-0.5 rounded border border-primary/30 bg-primary/5 px-1.5 py-0.5 text-[10px] text-primary">
<Star className="size-2.5" />
Default
</span>
)}
{branch.protected && (
<span className="inline-flex items-center gap-0.5 rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
<Shield className="size-2.5" />
Protected
{branch.is_merged && (
<span className="rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
Merged
</span>
)}
<code className="ml-auto text-[10px] text-muted-foreground/60">
{branch.commit_sha.slice(0, 7)}
</code>
{branch.last_push_at && (
<span className="text-[10px] text-muted-foreground/50">{timeAgo(branch.last_push_at)}</span>
{commitSha && (
<code className="ml-auto text-[10px] text-muted-foreground/60">{commitSha.slice(0, 7)}</code>
)}
{!isDefault && (
{!isDef && (
<div className="flex items-center gap-0.5">
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSetDefault.mutate(branch.id)}
onClick={() => onSetDefault.mutate(branch.name)}
title="Set as default branch"
>
<Star className="size-3" />
@@ -197,7 +195,7 @@ function BranchRow({ branch, isDefault, onDelete, onSetDefault }: BranchRowProps
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete branch "${branch.name}"?`)) onDelete.mutate(branch.id);
if (confirm(`Delete branch "${branch.name}"?`)) onDelete.mutate(branch.name);
}}
>
<Trash2 className="size-3.5" />
+244
View File
@@ -0,0 +1,244 @@
import { File, FileCode, FileImage, FileText, Folder } from 'lucide-react';
import { useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { useGitBlob } from '@/hooks/repo/useGitBlob';
import { useGitTree } from '@/hooks/repo/useGitTree';
import { useRepo } from '@/hooks/repo/useRepo';
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
import { cn } from '@/lib/utils';
export function RepoCodePage() {
const { workspaceName = '', repoName = '' } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const currentBranch = searchParams.get('branch') || '';
const currentPath = searchParams.get('path') || '';
const { data: repo } = useRepo(workspaceName, repoName);
const { data: branches } = useRepoBranches(workspaceName, repoName);
const { data: tree, isLoading } = useGitTree(
workspaceName,
repoName,
currentBranch || repo?.default_branch || 'HEAD',
currentPath || undefined,
);
const [showBranchSelector, setShowBranchSelector] = useState(false);
const updateParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === '') next.delete(key);
else next.set(key, value);
}
setSearchParams(next, { replace: true });
};
const effectiveBranch = currentBranch || repo?.default_branch || '';
const pathParts = currentPath ? currentPath.split('/').filter(Boolean) : [];
const breadcrumbs = [
{ label: repoName, path: '' },
...pathParts.map((part, idx) => ({
label: part,
path: pathParts.slice(0, idx + 1).join('/'),
})),
];
const entries = tree?.entries ?? [];
const dirs = entries.filter((e) => e.type === 2).sort((a, b) => a.name.localeCompare(b.name));
const files = entries.filter((e) => e.type !== 2).sort((a, b) => a.name.localeCompare(b.name));
const sortedEntries = [...dirs, ...files];
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setShowBranchSelector(!showBranchSelector)}
>
{effectiveBranch || 'Select branch'}
</Button>
{showBranchSelector && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="max-h-64 overflow-y-auto p-1">
{branches?.map((branch: { name: string; id?: string }) => (
<button
key={branch.id || branch.name}
type="button"
className={cn(
'w-full rounded px-3 py-1.5 text-left text-[13px] transition-colors',
branch.name === effectiveBranch
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-muted',
)}
onClick={() => {
updateParams({ branch: branch.name });
setShowBranchSelector(false);
}}
>
{branch.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="flex items-center gap-1 text-[13px]">
{breadcrumbs.map((crumb, idx) => (
<span key={crumb.path || `root-${idx}`} className="flex items-center gap-1">
{idx > 0 && <span className="text-muted-foreground">/</span>}
<button
type="button"
className="text-foreground hover:text-primary hover:underline"
onClick={() => updateParams({ path: crumb.path || null })}
>
{crumb.label}
</button>
</span>
))}
</div>
<Link
to={`/${workspaceName}/repos/${repoName}/commits?branch=${effectiveBranch}`}
className="ml-auto text-[12px] text-muted-foreground hover:text-foreground"
>
History
</Link>
</div>
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : sortedEntries.length > 0 ? (
<div className="divide-y divide-border">
{currentPath && (
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-2 text-left text-[13px] transition-colors hover:bg-muted"
onClick={() => {
const parentPath = pathParts.slice(0, -1).join('/');
updateParams({ path: parentPath || null });
}}
>
<Folder className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">..</span>
</button>
)}
{sortedEntries.map((entry) => (
<button
key={entry.path}
type="button"
className="flex w-full items-center gap-2 px-4 py-2 text-left text-[13px] transition-colors hover:bg-muted"
onClick={() => {
if (entry.type === 2) {
updateParams({ path: entry.path });
} else {
const params = new URLSearchParams();
if (effectiveBranch) params.set('branch', effectiveBranch);
params.set('blob', entry.path);
navigate(`/${workspaceName}/repos/${repoName}/blob?${params}`);
}
}}
>
{entry.type === 2 ? (
<Folder className="size-4 text-blue-500" />
) : (
<FileIcon filename={entry.name} />
)}
<span className="flex-1 text-foreground">{entry.name}</span>
{entry.size > 0 && entry.type !== 2 && (
<span className="text-[11px] text-muted-foreground">
{formatSize(entry.size)}
</span>
)}
</button>
))}
</div>
) : (
<div className="py-12 text-center text-[13px] text-muted-foreground">
This directory is empty
</div>
)}
</div>
<ReadmePreview
workspaceName={workspaceName}
repoName={repoName}
branch={effectiveBranch}
files={files}
/>
</div>
);
}
function FileIcon({ filename }: { filename: string }) {
const ext = filename.split('.').pop()?.toLowerCase();
if (['md', 'txt', 'rst'].includes(ext || '')) {
return <FileText className="size-4 text-muted-foreground" />;
}
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext || '')) {
return <FileImage className="size-4 text-muted-foreground" />;
}
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'java', 'cpp', 'c', 'h'].includes(ext || '')) {
return <FileCode className="size-4 text-muted-foreground" />;
}
return <File className="size-4 text-muted-foreground" />;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadmePreview({
workspaceName,
repoName,
branch,
files,
}: {
workspaceName: string;
repoName: string;
branch: string;
files: Array<{ name: string; path: string }>;
}) {
const readmeFile = files.find((f) =>
['README.md', 'readme.md', 'README', 'README.txt'].includes(f.name),
);
const { data: blob } = useGitBlob(
readmeFile ? workspaceName : null,
readmeFile ? repoName : null,
readmeFile ? branch : null,
readmeFile ? readmeFile.path : null,
);
if (!readmeFile || !blob) return null;
const content = new TextDecoder().decode(new Uint8Array(blob.data));
return (
<div className="overflow-hidden rounded-lg border border-border">
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2">
<h3 className="text-[13px] font-semibold text-foreground">{readmeFile.name}</h3>
<Link
to={`/${workspaceName}/repos/${repoName}/blob?branch=${branch}&blob=${readmeFile.path}`}
className="text-[11px] text-primary hover:underline"
>
View raw
</Link>
</div>
<div className="max-h-96 overflow-y-auto p-4">
<pre className="whitespace-pre-wrap text-[13px] text-foreground">{content}</pre>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { ArrowLeft, Calendar, GitCommit, User } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useGitCommit } from '@/hooks/repo/useGitCommit';
import { useGitDiff } from '@/hooks/repo/useGitDiff';
import { timeAgo } from '@/lib/utils';
export function RepoCommitDetailPage() {
const { workspaceName = '', repoName = '', commitSha = '' } = useParams();
const { data: commit, isLoading: loadingCommit } = useGitCommit(
workspaceName,
repoName,
commitSha,
);
const parentSha = commit?.parent_oids?.[0]?.hex;
const { data: diff, isLoading: loadingDiff } = useGitDiff(
workspaceName,
repoName,
parentSha || null,
commitSha,
);
if (loadingCommit) {
return (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
);
}
if (!commit) {
return (
<div className="py-12 text-center">
<GitCommit className="mx-auto size-5 text-muted-foreground/20" />
<p className="mt-3 text-[13px] text-muted-foreground">Commit not found</p>
<Link
to={`/${workspaceName}/repos/${repoName}/commits`}
className="mt-2 inline-block text-[13px] text-primary hover:underline"
>
Back to commits
</Link>
</div>
);
}
const author = commit.author?.identity;
const authoredDate = commit.authored_at
? new Date(commit.authored_at.seconds * 1000).toISOString()
: null;
return (
<div className="space-y-6">
<Link
to={`/${workspaceName}/repos/${repoName}/commits`}
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
Back to commits
</Link>
<div className="rounded-xl border border-border bg-card p-5">
<h1 className="text-xl font-semibold text-foreground">{commit.subject}</h1>
{commit.body && (
<pre className="mt-3 whitespace-pre-wrap text-[13px] text-muted-foreground">
{commit.body}
</pre>
)}
<div className="mt-4 flex flex-wrap items-center gap-4 text-[12px] text-muted-foreground">
{author && (
<span className="flex items-center gap-1.5">
<User className="size-3.5" />
<span className="font-medium text-foreground">{author.name}</span>
<span>{'<'}{author.email}{'>'}</span>
</span>
)}
{authoredDate && (
<span className="flex items-center gap-1.5">
<Calendar className="size-3.5" />
{timeAgo(authoredDate)}
</span>
)}
<code className="rounded bg-muted px-2 py-0.5 text-[11px]">
{commit.abbreviated_oid}
</code>
{commit.parent_oids.length > 0 && (
<span className="text-[11px]">
parent{' '}
{commit.parent_oids.map((p, i) => (
<Link
key={i}
to={`/${workspaceName}/repos/${repoName}/commits/${p.hex.slice(0, 7)}`}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80"
>
{p.hex.slice(0, 7)}
</Link>
))}
</span>
)}
</div>
{commit.stats && (
<div className="mt-3 flex items-center gap-3 text-[12px]">
<span className="text-green-600">+{commit.stats.additions}</span>
<span className="text-red-600">-{commit.stats.deletions}</span>
<span className="text-muted-foreground">{commit.stats.changed_files} files changed</span>
</div>
)}
</div>
{loadingDiff ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
) : diff?.files && diff.files.length > 0 ? (
<div className="space-y-3">
{diff.stats && (
<div className="flex items-center gap-3 text-[12px] text-muted-foreground">
<span>{diff.stats.changed_files} files changed</span>
<span className="text-green-600">+{diff.stats.additions}</span>
<span className="text-red-600">-{diff.stats.deletions}</span>
</div>
)}
{diff.files.map((file, fileIdx) => (
<div
key={fileIdx}
className="overflow-hidden rounded-lg border border-border"
>
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
<span className="text-[13px] font-medium text-foreground">{file.new_path}</span>
<span className="text-[11px] text-green-600">+{file.additions}</span>
<span className="text-[11px] text-red-600">-{file.deletions}</span>
{file.binary && (
<span className="text-[10px] text-muted-foreground">Binary</span>
)}
</div>
{!file.binary && (
<div className="overflow-x-auto">
{file.hunks.map((hunk, hunkIdx) => (
<div key={hunkIdx}>
<div className="bg-blue-500/5 px-4 py-1 text-[11px] text-muted-foreground">
{hunk.header}
</div>
{hunk.lines.map((line, lineIdx) => {
const lineContent = new TextDecoder().decode(
new Uint8Array(line.content),
);
const isAdd = line.type === 1;
const isDel = line.type === 2;
return (
<div
key={lineIdx}
className={`flex border-b border-border/30 last:border-b-0 ${
isAdd
? 'bg-green-500/10'
: isDel
? 'bg-red-500/10'
: ''
}`}
>
<span className="w-10 shrink-0 select-none px-2 text-right text-[11px] text-muted-foreground/50">
{line.old_line > 0 ? line.old_line : ''}
</span>
<span className="w-10 shrink-0 select-none px-2 text-right text-[11px] text-muted-foreground/50">
{line.new_line > 0 ? line.new_line : ''}
</span>
<pre className="flex-1 px-2 text-[12px]">{lineContent}</pre>
</div>
);
})}
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className="py-8 text-center text-[13px] text-muted-foreground">
No file changes in this commit
</div>
)}
</div>
);
}
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { Clock, GitCommit, Hash, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useGitCommits } from '@/hooks/repo/useGitCommits';
import { useRepo } from '@/hooks/repo/useRepo';
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
import { timeAgo } from '@/lib/utils';
export function RepoCommitsPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState('');
const [showBranchSelector, setShowBranchSelector] = useState(false);
const branch = searchParams.get('branch') || '';
const { data: repo } = useRepo(workspaceName, repoName);
const { data: branches } = useRepoBranches(workspaceName, repoName);
const { data: commitsData, isLoading } = useGitCommits(
workspaceName,
repoName,
branch || repo?.default_branch,
);
const effectiveBranch = branch || repo?.default_branch || '';
const commits = commitsData?.commits ?? [];
const filtered = search
? commits.filter(
(c) =>
c.subject.toLowerCase().includes(search.toLowerCase()) ||
c.abbreviated_oid.includes(search),
)
: commits;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setShowBranchSelector(!showBranchSelector)}
>
{effectiveBranch || 'Select branch'}
</Button>
{showBranchSelector && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="max-h-64 overflow-y-auto p-1">
{branches?.map((b: { name: string; id?: string }) => (
<button
key={b.id || b.name}
type="button"
className={`w-full rounded px-3 py-1.5 text-left text-[13px] transition-colors ${
b.name === effectiveBranch
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-muted'
}`}
onClick={() => {
setSearchParams({ branch: b.name }, { replace: true });
setShowBranchSelector(false);
}}
>
{b.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="relative ml-auto max-w-[300px]">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-[13px]"
placeholder="Search commits..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : filtered.length > 0 ? (
<div className="divide-y divide-border">
{filtered.map((commit) => (
<Link
key={commit.abbreviated_oid}
to={`/${workspaceName}/repos/${repoName}/commits/${commit.abbreviated_oid}`}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/40"
>
<GitCommit className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">
{commit.subject}
</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{commit.author?.identity?.name || 'Unknown'}</span>
{commit.authored_at && (
<span className="flex items-center gap-0.5">
<Clock className="size-2.5" />
{timeAgo(
new Date(
commit.authored_at.seconds * 1000,
).toISOString(),
)}
</span>
)}
</div>
</div>
<code className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
<Hash className="size-2.5" />
{commit.abbreviated_oid}
</code>
</Link>
))}
</div>
) : (
<div className="py-12 text-center">
<GitCommit className="mx-auto size-5 text-muted-foreground/20" />
<p className="mt-3 text-[13px] text-muted-foreground">
{search ? `No commits matching "${search}"` : 'No commits yet'}
</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, GitPullRequest } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCreatePr } from '@/hooks/pull-request/useCreatePr';
import { useRepo } from '@/hooks/repo/useRepo';
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
export function RepoCreatePrPage() {
const { workspaceName = '', repoName = '' } = useParams();
const navigate = useNavigate();
const { data: repo } = useRepo(workspaceName, repoName);
const { data: branches } = useRepoBranches(workspaceName, repoName);
const createPr = useCreatePr(workspaceName, repoName);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [sourceBranch, setSourceBranch] = useState('');
const [targetBranch, setTargetBranch] = useState(repo?.default_branch || '');
const [draft, setDraft] = useState(false);
const branchNames = (branches ?? []).map((b: { name: string }) => b.name);
const handleSubmit = () => {
if (!title.trim() || !sourceBranch || !targetBranch) return;
createPr.mutate(
{
title: title.trim(),
body: body.trim() || undefined,
source_branch: sourceBranch,
target_branch: targetBranch,
source_repo_id: repo?.id || '',
head_commit_sha: '',
draft,
},
{
onSuccess: () => {
navigate(`/${workspaceName}/repos/${repoName}/pulls`);
},
},
);
};
return (
<div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 text-[13px] text-muted-foreground hover:text-foreground"
onClick={() => navigate(`/${workspaceName}/repos/${repoName}/pulls`)}
>
<ArrowLeft className="size-3.5" />
Back to pull requests
</button>
</div>
<div>
<div className="flex items-center gap-2">
<GitPullRequest className="size-5 text-green-500" />
<h1 className="text-lg font-semibold text-foreground">Create pull request</h1>
</div>
</div>
<div className="space-y-5">
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4">
<select
className="h-9 rounded-lg border border-border bg-transparent px-3 text-[13px] text-foreground"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
>
<option value="">Select source branch</option>
{branchNames.map((name: string) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<span className="text-muted-foreground"></span>
<select
className="h-9 rounded-lg border border-border bg-transparent px-3 text-[13px] text-foreground"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
>
<option value="">Select target branch</option>
{branchNames.map((name: string) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="pr-title" className="text-[13px] font-medium text-foreground">
Title
</label>
<Input
id="pr-title"
className="mt-1 h-10 text-[14px]"
placeholder="Pull request title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<label htmlFor="pr-body" className="text-[13px] font-medium text-foreground">
Description <span className="text-muted-foreground">(optional)</span>
</label>
<textarea
id="pr-body"
className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-[14px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={8}
placeholder="Describe your changes..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={draft}
onChange={(e) => setDraft(e.target.checked)}
className="rounded border-border"
/>
Create as draft
</label>
<div className="flex items-center gap-3">
<Button
disabled={
!title.trim() ||
!sourceBranch ||
!targetBranch ||
sourceBranch === targetBranch ||
createPr.isPending
}
onClick={handleSubmit}
>
{createPr.isPending ? 'Creating...' : 'Create pull request'}
</Button>
<Button
variant="outline"
onClick={() => navigate(`/${workspaceName}/repos/${repoName}/pulls`)}
>
Cancel
</Button>
</div>
{sourceBranch === targetBranch && sourceBranch && (
<p className="text-[12px] text-amber-600">
Source and target branches must be different
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Key, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useAddDeployKey } from '@/hooks/repo/useAddDeployKey';
import { useDeleteDeployKey } from '@/hooks/repo/useDeleteDeployKey';
import { useRepoDeployKeys } from '@/hooks/repo/useRepoDeployKeys';
import { cn, timeAgo } from '@/lib/utils';
export function RepoDeployKeysPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [showAdd, setShowAdd] = useState(false);
const [title, setTitle] = useState('');
const [publicKey, setPublicKey] = useState('');
const [readOnly, setReadOnly] = useState(true);
const { data: keys, isLoading } = useRepoDeployKeys(workspaceName, repoName);
const addKey = useAddDeployKey();
const deleteKey = useDeleteDeployKey();
const handleAdd = () => {
if (!title.trim() || !publicKey.trim()) return;
const keyType = publicKey.startsWith('ssh-ed25519')
? 'Ed25519'
: publicKey.startsWith('ecdsa-')
? 'Ecdsa'
: 'Rsa';
addKey.mutate(
{
title: title.trim(),
public_key: publicKey.trim(),
key_type: keyType,
read_only: readOnly,
},
{
onSuccess: () => {
setTitle('');
setPublicKey('');
setReadOnly(true);
setShowAdd(false);
},
},
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[12px] text-muted-foreground">
{(keys ?? []).length} deploy key{(keys ?? []).length !== 1 ? 's' : ''}
</p>
<Button size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="size-3.5" />
Add deploy key
</Button>
</div>
{showAdd && (
<div className="space-y-3 rounded-lg border border-border p-4">
<Input
className="h-9 text-[13px]"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[12px] font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={4}
placeholder="Paste your public key (ssh-rsa AAAA... or ssh-ed25519 AAAA...)"
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
/>
<label className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={readOnly}
onChange={(e) => setReadOnly(e.target.checked)}
className="rounded border-border"
/>
Read-only access
</label>
<div className="flex gap-2">
<Button
size="sm"
disabled={!title.trim() || !publicKey.trim() || addKey.isPending}
onClick={handleAdd}
>
{addKey.isPending ? 'Adding...' : 'Add key'}
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (keys ?? []).length > 0 ? (
<div className="divide-y divide-border">
{(keys ?? []).map((key) => (
<div
key={key.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40"
>
<Key className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-foreground">{key.title}</span>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="rounded bg-muted px-1 py-0.5 text-[10px]">
{key.key_type}
</span>
<span
className={cn(
key.read_only ? 'text-muted-foreground' : 'text-green-600',
)}
>
{key.read_only ? 'Read-only' : 'Read-write'}
</span>
<code className="text-[10px] opacity-60">
{key.fingerprint_sha256.slice(0, 16)}...
</code>
<span>Added {timeAgo(key.created_at)}</span>
{key.last_used_at && (
<span>· Used {timeAgo(key.last_used_at)}</span>
)}
</div>
</div>
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Remove deploy key "${key.title}"?`))
deleteKey.mutate(key.id);
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Key className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No deploy keys configured</p>
<Button size="sm" className="mt-3" onClick={() => setShowAdd(true)}>
<Plus className="size-3.5" />
Add first key
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { GitFork } from 'lucide-react';
import { Pagination } from '@/components/repo/Pagination';
import { Spinner } from '@/components/ui/spinner';
import { useRepoForks } from '@/hooks/repo/useRepoForks';
import { timeAgo } from '@/lib/utils';
const LIMIT = 20;
export function RepoForksPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [offset, setOffset] = useState(0);
const { data: forks, isLoading } = useRepoForks(workspaceName, repoName, LIMIT, offset);
return (
<div className="space-y-4">
<p className="text-[12px] text-muted-foreground">
{(forks ?? []).length} fork{(forks ?? []).length !== 1 ? 's' : ''}
</p>
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (forks ?? []).length > 0 ? (
<>
<div className="divide-y divide-border">
{(forks ?? []).map((fork) => (
<div
key={fork.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40"
>
<GitFork className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-foreground">
{fork.fork_repo_id}
</span>
<div className="mt-0.5 text-[11px] text-muted-foreground">
Forked by {fork.forked_by} · {timeAgo(fork.created_at)}
</div>
</div>
</div>
))}
</div>
<Pagination
offset={offset}
limit={LIMIT}
hasMore={(forks ?? []).length >= LIMIT}
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
onNext={() => setOffset(offset + LIMIT)}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<GitFork className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No forks yet</p>
</div>
)}
</div>
</div>
);
}
+65 -11
View File
@@ -2,34 +2,35 @@ import {
Archive,
BookOpen,
Code,
Eye,
GitBranch,
GitCommit,
GitFork,
GitPullRequest,
Globe,
Key,
Lock,
Settings,
Star,
Tag,
Users,
Webhook,
} from 'lucide-react';
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useRepo } from '@/hooks/repo/useRepo';
import { useRepoStats } from '@/hooks/repo/useRepoStats';
import { useStarRepo, useUnstarRepo } from '@/hooks/repo/useStarRepo';
import { cn } from '@/lib/utils';
const TABS = [
{ label: 'Overview', to: '', icon: BookOpen, end: true },
{ label: 'Branches', to: 'branches', icon: GitBranch, end: false },
{ label: 'Tags', to: 'tags', icon: Tag, end: false },
{ label: 'Pull Requests', to: 'pulls', icon: GitPullRequest, end: false },
{ label: 'Wiki', to: 'wiki', icon: BookOpen, end: false },
{ label: 'Webhooks', to: 'webhooks', icon: Webhook, end: false },
{ label: 'Settings', to: 'settings', icon: Settings, end: false },
] as const;
export function RepoLayout() {
const { workspaceName = '', repoName = '' } = useParams();
const navigate = useNavigate();
const { data: repo, isLoading, error } = useRepo(workspaceName, repoName);
const { data: stats } = useRepoStats(workspaceName, repoName);
const starRepo = useStarRepo();
const unstarRepo = useUnstarRepo();
if (isLoading) {
return (
@@ -63,6 +64,29 @@ export function RepoLayout() {
const base = `/${workspaceName}/repos/${repoName}`;
const TABS = [
{ label: 'Code', to: '', icon: Code, end: true },
{ label: 'Commits', to: 'commits', icon: GitCommit, end: false },
{ label: 'Branches', to: 'branches', icon: GitBranch, end: false },
{ label: 'Tags', to: 'tags', icon: Tag, end: false },
{ label: 'Releases', to: 'releases', icon: Tag, end: false },
{
label: 'Pull Requests',
to: 'pulls',
icon: GitPullRequest,
end: false,
count: stats?.open_pull_requests_count,
},
{ label: 'Wiki', to: 'wiki', icon: BookOpen, end: false },
{ label: 'Members', to: 'members', icon: Users, end: false },
{ label: 'Forks', to: 'forks', icon: GitFork, end: false, count: stats?.forks_count },
{ label: 'Stars', to: 'stars', icon: Star, end: false, count: stats?.stars_count },
{ label: 'Watchers', to: 'watchers', icon: Eye, end: false, count: stats?.watchers_count },
{ label: 'Deploy Keys', to: 'deploy-keys', icon: Key, end: false },
{ label: 'Webhooks', to: 'webhooks', icon: Webhook, end: false },
{ label: 'Settings', to: 'settings', icon: Settings, end: false },
] as const;
return (
<div className="px-6 py-6">
<div className="flex items-center gap-2 flex-wrap">
@@ -76,9 +100,34 @@ export function RepoLayout() {
)}
{repo.is_fork && (
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<GitFork className="size-3" />
Fork
</span>
)}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={() => starRepo.mutate()}
>
<Star className="size-3" />
Star
{stats?.stars_count != null && (
<span className="text-muted-foreground">{stats.stars_count}</span>
)}
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={() => unstarRepo.mutate()}
>
<Eye className="size-3" />
Watch
{stats?.watchers_count != null && (
<span className="text-muted-foreground">{stats.watchers_count}</span>
)}
</button>
</div>
</div>
{repo.description && (
@@ -96,7 +145,7 @@ export function RepoLayout() {
<NavLink
key={tab.to}
to={tab.to === '' ? base : `${base}/${tab.to}`}
end={tab.end}
end={'end' in tab && tab.end}
className={({ isActive }) =>
cn(
'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors',
@@ -108,6 +157,11 @@ export function RepoLayout() {
>
<tab.icon className="size-3.5" />
{tab.label}
{'count' in tab && tab.count != null && tab.count > 0 && (
<span className="rounded-full bg-muted px-1.5 py-0 text-[10px]">
{tab.count}
</span>
)}
</NavLink>
))}
</nav>
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Plus, Trash2, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useAddRepoMember } from '@/hooks/repo/useAddRepoMember';
import { useRemoveRepoMember } from '@/hooks/repo/useRemoveRepoMember';
import { useRepoMembers } from '@/hooks/repo/useRepoMembers';
import { useUpdateRepoMemberRole } from '@/hooks/repo/useUpdateRepoMemberRole';
import { cn, timeAgo } from '@/lib/utils';
import type { Role } from '@/client/models/Role';
const ROLES: Role[] = ['Owner', 'Admin', 'Maintainer', 'Member', 'Viewer'];
export function RepoMembersPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [showAdd, setShowAdd] = useState(false);
const [newUserId, setNewUserId] = useState('');
const [newRole, setNewRole] = useState<Role>('Member');
const { data: members, isLoading } = useRepoMembers(workspaceName, repoName, 100);
const addMember = useAddRepoMember();
const removeMember = useRemoveRepoMember();
const updateRole = useUpdateRepoMemberRole();
const handleAdd = () => {
if (!newUserId.trim()) return;
addMember.mutate(
{ userId: newUserId.trim(), role: newRole },
{
onSuccess: () => {
setNewUserId('');
setNewRole('Member');
setShowAdd(false);
},
},
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[12px] text-muted-foreground">
{(members ?? []).length} member{(members ?? []).length !== 1 ? 's' : ''}
</p>
<Button size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="size-3.5" />
Add member
</Button>
</div>
{showAdd && (
<div className="flex items-center gap-2 rounded-lg border border-border p-3">
<Input
className="h-8 flex-1 text-[13px]"
placeholder="User ID"
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
<select
className="h-8 rounded-lg border border-border bg-transparent px-2 text-[13px] text-foreground"
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<Button size="sm" disabled={!newUserId.trim() || addMember.isPending} onClick={handleAdd}>
Add
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
Cancel
</Button>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (members ?? []).length > 0 ? (
<div className="divide-y divide-border">
{(members ?? []).map((member) => (
<div
key={member.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
<User className="size-4" />
</div>
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-foreground">
{member.user_id}
</span>
<div className="text-[11px] text-muted-foreground">
Joined {timeAgo(member.created_at)}
{member.last_active_at && ` · Active ${timeAgo(member.last_active_at)}`}
</div>
</div>
<select
className="h-7 rounded border border-border bg-transparent px-2 text-[12px] text-foreground"
value={member.role}
onChange={(e) =>
updateRole.mutate({
memberId: member.id,
role: e.target.value as Role,
})
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<RoleBadge role={member.role} />
{member.role !== 'Owner' && (
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Remove member?`)) removeMember.mutate(member.id);
}}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<User className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No members</p>
</div>
)}
</div>
</div>
);
}
function RoleBadge({ role }: { role: string }) {
const colors: Record<string, string> = {
Owner: 'text-purple-600 bg-purple-500/10',
Admin: 'text-red-600 bg-red-500/10',
Maintainer: 'text-blue-600 bg-blue-500/10',
Member: 'text-muted-foreground bg-muted',
Viewer: 'text-muted-foreground/70 bg-muted/50',
};
return (
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium',
colors[role] || colors.Member,
)}
>
{role}
</span>
);
}
+66 -29
View File
@@ -1,18 +1,22 @@
import {
BookOpen,
Eye,
GitBranch,
GitCommit,
GitFork,
GitPullRequest,
HardDrive,
Key,
Star,
Tag,
Users,
Webhook,
} from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useRepo } from '@/hooks/repo/useRepo';
import { useRepoStats } from '@/hooks/repo/useRepoStats';
import { timeAgo } from '@/lib/utils';
export function RepoOverviewPage() {
const { workspaceName = '', repoName = '' } = useParams();
@@ -27,34 +31,47 @@ export function RepoOverviewPage() {
);
}
const base = `/${workspaceName}/repos/${repoName}`;
const cards = [
{ icon: GitBranch, label: 'Branches', value: stats?.branches_count ?? 0 },
{ icon: Tag, label: 'Tags', value: stats?.tags_count ?? 0 },
{ icon: BookOpen, label: 'Commits', value: stats?.commits_count ?? 0 },
{ icon: Star, label: 'Stars', value: stats?.stars_count ?? 0 },
{ icon: Eye, label: 'Watchers', value: stats?.watchers_count ?? 0 },
{ icon: GitFork, label: 'Forks', value: stats?.forks_count ?? 0 },
{ icon: GitPullRequest, label: 'Open PRs', value: stats?.open_pull_requests_count ?? 0 },
{ icon: HardDrive, label: 'Size', value: formatSize(stats?.size_bytes ?? 0) },
{ icon: GitBranch, label: 'Branches', value: stats?.branches_count ?? 0, to: `${base}/branches` },
{ icon: Tag, label: 'Tags', value: stats?.tags_count ?? 0, to: `${base}/tags` },
{ icon: Tag, label: 'Releases', value: stats?.releases_count ?? 0, to: `${base}/releases` },
{ icon: GitCommit, label: 'Commits', value: stats?.commits_count ?? 0, to: `${base}/commits` },
{ icon: Star, label: 'Stars', value: stats?.stars_count ?? 0, to: `${base}/stars` },
{ icon: Eye, label: 'Watchers', value: stats?.watchers_count ?? 0, to: `${base}/watchers` },
{ icon: GitFork, label: 'Forks', value: stats?.forks_count ?? 0, to: `${base}/forks` },
{ icon: GitPullRequest, label: 'Open PRs', value: stats?.open_pull_requests_count ?? 0, to: `${base}/pulls` },
{ icon: Users, label: 'Members', value: '—', to: `${base}/members` },
{ icon: HardDrive, label: 'Size', value: formatSize(stats?.size_bytes ?? 0), to: null },
{ icon: Key, label: 'Deploy Keys', value: '—', to: `${base}/deploy-keys` },
{ icon: Webhook, label: 'Webhooks', value: '—', to: `${base}/webhooks` },
];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{cards.map((card) => (
<div
key={card.label}
className="flex items-center gap-3 rounded-xl border border-border bg-card p-4"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<card.icon className="size-4 text-muted-foreground" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{cards.map((card) => {
const content = (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-muted-foreground/30">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<card.icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-foreground">{card.value}</p>
<p className="text-[11px] text-muted-foreground">{card.label}</p>
</div>
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-foreground">{card.value}</p>
<p className="text-[11px] text-muted-foreground">{card.label}</p>
</div>
</div>
))}
);
return card.to ? (
<Link key={card.label} to={card.to}>
{content}
</Link>
) : (
<div key={card.label}>{content}</div>
);
})}
</div>
<div className="rounded-xl border border-border bg-card p-5">
@@ -62,7 +79,14 @@ export function RepoOverviewPage() {
<dl className="mt-3 space-y-2 text-[13px]">
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Default branch</dt>
<dd className="text-foreground">{repo.default_branch}</dd>
<dd className="text-foreground">
<Link
to={`${base}?branch=${repo.default_branch}`}
className="text-primary hover:underline"
>
{repo.default_branch}
</Link>
</dd>
</div>
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Visibility</dt>
@@ -74,17 +98,30 @@ export function RepoOverviewPage() {
</div>
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Created</dt>
<dd className="text-foreground">{new Date(repo.created_at).toLocaleDateString()}</dd>
<dd className="text-foreground">{timeAgo(repo.created_at)}</dd>
</div>
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Updated</dt>
<dd className="text-foreground">{new Date(repo.updated_at).toLocaleDateString()}</dd>
<dd className="text-foreground">{timeAgo(repo.updated_at)}</dd>
</div>
{stats?.last_push_at && (
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Last push</dt>
<dd className="text-foreground">
{new Date(stats.last_push_at).toLocaleDateString()}
<dd className="text-foreground">{timeAgo(stats.last_push_at)}</dd>
</div>
)}
{repo.topics.length > 0 && (
<div className="flex gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">Topics</dt>
<dd className="flex flex-wrap gap-1">
{repo.topics.map((topic) => (
<span
key={topic}
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary"
>
{topic}
</span>
))}
</dd>
</div>
)}
+101 -23
View File
@@ -11,22 +11,32 @@ import {
Send,
} from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useClosePr } from '@/hooks/pull-request/useClosePr';
import { useCreateReview } from '@/hooks/pull-request/useCreateReview';
import { useMergePr } from '@/hooks/pull-request/useMergePr';
import { usePrDetail } from '@/hooks/pull-request/usePrDetail';
import { usePrFiles } from '@/hooks/pull-request/usePrFiles';
import { usePrReviews } from '@/hooks/pull-request/usePrReviews';
import { useReopenPr } from '@/hooks/pull-request/useReopenPr';
import { cn, timeAgo } from '@/lib/utils';
export function RepoPrDetailPage() {
const { workspaceName = '', repoName = '', prNumber } = useParams();
const navigate = useNavigate();
const number = Number(prNumber);
const { data: pr, isLoading, error } = usePrDetail(workspaceName, repoName, number);
const { data: files } = usePrFiles(workspaceName, repoName, number);
const { data: reviews } = usePrReviews(workspaceName, repoName, number);
const mergePr = useMergePr(workspaceName, repoName, number);
const closePr = useClosePr(workspaceName, repoName, number);
const reopenPr = useReopenPr(workspaceName, repoName, number);
const createReview = useCreateReview();
const [commentBody, setCommentBody] = useState('');
const [activeTab, setActiveTab] = useState<'files' | 'reviews'>('files');
@@ -63,17 +73,59 @@ export function RepoPrDetailPage() {
const isClosed = pr.state === 'Closed' || isMerged;
const isOpen = !isClosed;
const handleMerge = () => {
if (confirm('Merge this pull request?')) {
mergePr.mutate(
{},
{ onSuccess: () => navigate(`/${workspaceName}/repos/${repoName}/pulls`) },
);
}
};
const handleClose = () => {
if (confirm('Close this pull request?')) {
closePr.mutate();
}
};
const handleReopen = () => {
reopenPr.mutate();
};
const handleComment = () => {
if (!commentBody.trim()) return;
createReview.mutate(
{
number,
body: commentBody.trim(),
state: 'Commented',
},
{
onSuccess: () => setCommentBody(''),
},
);
};
const handleApprove = () => {
createReview.mutate({
number,
body: commentBody.trim() || undefined,
state: 'Approved',
});
setCommentBody('');
};
return (
<div className="px-6 pt-6 pb-10">
<div className="space-y-6">
<Link
to={`/${workspaceName}/repos/${repoName}/pulls`}
className="mb-4 inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3.5" />
Back to pull requests
</Link>
<div className="mb-6">
<div>
<div className="flex items-start gap-3">
<div className="mt-1 shrink-0">
{isMerged ? (
@@ -109,12 +161,18 @@ export function RepoPrDetailPage() {
Draft
</span>
)}
{pr.author && (
<span>
opened by <span className="font-medium text-foreground">{pr.author.username}</span>
</span>
)}
<span>{timeAgo(pr.created_at)}</span>
</div>
</div>
</div>
</div>
<div className="mb-6 flex items-center gap-3 rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4">
<GitBranch className="size-4 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">{pr.source_branch}</span>
<span className="text-muted-foreground"></span>
@@ -122,14 +180,14 @@ export function RepoPrDetailPage() {
</div>
{pr.body && (
<div className="mb-8 rounded-xl border border-border bg-card p-5">
<div className="rounded-xl border border-border bg-card p-5">
<div className="prose prose-sm max-w-none text-[14px] text-foreground whitespace-pre-wrap">
{pr.body}
</div>
</div>
)}
<div className="mb-4 flex items-center gap-1 border-b border-border">
<div className="flex items-center gap-1 border-b border-border">
<button
type="button"
onClick={() => setActiveTab('files')}
@@ -169,7 +227,7 @@ export function RepoPrDetailPage() {
</div>
{activeTab === 'files' && (
<div className="mb-8 space-y-2">
<div className="space-y-2">
{files && files.length > 0 ? (
files.map((file) => (
<div
@@ -207,7 +265,7 @@ export function RepoPrDetailPage() {
)}
{activeTab === 'reviews' && (
<div className="mb-8 space-y-4">
<div className="space-y-4">
{reviews && reviews.length > 0 ? (
reviews.map((review) => (
<div key={review.id} className="rounded-xl border border-border bg-card p-4">
@@ -253,6 +311,8 @@ export function RepoPrDetailPage() {
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={handleApprove}
disabled={createReview.isPending}
>
<CircleCheck className="size-3.5 text-purple-500" />
Approve
@@ -260,7 +320,8 @@ export function RepoPrDetailPage() {
)}
<button
type="button"
disabled={!commentBody.trim()}
disabled={!commentBody.trim() || createReview.isPending}
onClick={handleComment}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-[5px] text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/85 disabled:opacity-50"
>
<Send className="size-3.5" />
@@ -270,24 +331,41 @@ export function RepoPrDetailPage() {
</div>
</div>
{isOpen && (
<div className="mt-4 flex items-center gap-3">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-[5px] text-[13px] font-medium text-white transition-colors hover:bg-green-700"
>
<GitMerge className="size-3.5" />
Merge
</button>
<div className="flex items-center gap-3">
{isOpen && (
<>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-[5px] text-[13px] font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50"
onClick={handleMerge}
disabled={mergePr.isPending}
>
<GitMerge className="size-3.5" />
{mergePr.isPending ? 'Merging...' : 'Merge'}
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={handleClose}
disabled={closePr.isPending}
>
<CircleDot className="size-3.5 text-red-500" />
Close PR
</button>
</>
)}
{isClosed && !isMerged && (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={handleReopen}
disabled={reopenPr.isPending}
>
<CircleDot className="size-3.5 text-red-500" />
Close PR
<Circle className="size-3.5 text-green-500" />
Reopen PR
</button>
</div>
)}
)}
</div>
</div>
);
}
+13 -1
View File
@@ -1,8 +1,9 @@
import { Circle, CircleDot, GitMerge, GitPullRequest, Search } from 'lucide-react';
import { Circle, CircleDot, GitMerge, GitPullRequest, Plus, Search } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import type { PullRequestDetail } from '@/client/models/PullRequestDetail';
import { Pagination } from '@/components/repo/Pagination';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useRepoPulls } from '@/hooks/repo/useRepoPulls';
@@ -48,6 +49,17 @@ export function RepoPullsPage() {
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-[12px] text-muted-foreground">
Showing pull requests
</p>
<Link to={`/${workspaceName}/repos/${repoName}/pulls/new`}>
<Button size="sm">
<Plus className="size-3.5" />
New pull request
</Button>
</Link>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm">
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<nav className="flex items-center gap-0.5">
@@ -0,0 +1,197 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Plus, Tag, Trash2 } from 'lucide-react';
import { Pagination } from '@/components/repo/Pagination';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useCreateRelease } from '@/hooks/repo/useCreateRelease';
import { useDeleteRelease } from '@/hooks/repo/useDeleteRelease';
import { useRepoReleases } from '@/hooks/repo/useRepoReleases';
import { timeAgo } from '@/lib/utils';
const LIMIT = 10;
export function RepoReleasesPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [offset, setOffset] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [tagName, setTagName] = useState('');
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [draft, setDraft] = useState(false);
const [prerelease, setPrerelease] = useState(false);
const { data: releases, isLoading } = useRepoReleases(workspaceName, repoName, LIMIT, offset);
const createRelease = useCreateRelease();
const deleteRelease = useDeleteRelease();
const handleCreate = () => {
if (!tagName.trim() || !title.trim()) return;
createRelease.mutate(
{
tag_name: tagName.trim(),
title: title.trim(),
body: body.trim() || undefined,
draft,
prerelease,
},
{
onSuccess: () => {
setTagName('');
setTitle('');
setBody('');
setDraft(false);
setPrerelease(false);
setShowCreate(false);
},
},
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[12px] text-muted-foreground">
{(releases ?? []).length} release{(releases ?? []).length !== 1 ? 's' : ''}
</p>
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus className="size-3.5" />
New release
</Button>
</div>
{showCreate && (
<div className="space-y-3 rounded-lg border border-border p-4">
<h3 className="text-[14px] font-semibold text-foreground">Create a new release</h3>
<Input
className="h-9 text-[13px]"
placeholder="Tag name (e.g. v1.0.0)"
value={tagName}
onChange={(e) => setTagName(e.target.value)}
/>
<Input
className="h-9 text-[13px]"
placeholder="Release title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
rows={4}
placeholder="Describe this release..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={draft}
onChange={(e) => setDraft(e.target.checked)}
className="rounded border-border"
/>
Draft
</label>
<label className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={prerelease}
onChange={(e) => setPrerelease(e.target.checked)}
className="rounded border-border"
/>
Pre-release
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={!tagName.trim() || !title.trim() || createRelease.isPending}
onClick={handleCreate}
>
{createRelease.isPending ? 'Creating...' : 'Create release'}
</Button>
<Button size="sm" variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (releases ?? []).length > 0 ? (
<>
{(releases ?? []).map((release) => (
<div
key={release.id}
className="rounded-xl border border-border bg-card p-5"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Tag className="size-4 text-primary" />
<h3 className="text-[16px] font-bold text-foreground">{release.title}</h3>
</div>
<div className="mt-1 flex items-center gap-2 text-[12px] text-muted-foreground">
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
{release.tag_name}
</code>
{release.draft && (
<span className="rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[10px]">
Draft
</span>
)}
{release.prerelease && (
<span className="rounded border border-amber-500/30 bg-amber-500/5 px-1.5 py-0.5 text-[10px] text-amber-600">
Pre-release
</span>
)}
<span>{timeAgo(release.published_at || release.created_at)}</span>
</div>
</div>
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete release "${release.title}"?`))
deleteRelease.mutate(release.id);
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
{release.body && (
<pre className="mt-3 whitespace-pre-wrap text-[13px] text-foreground">
{release.body}
</pre>
)}
</div>
))}
<Pagination
offset={offset}
limit={LIMIT}
hasMore={(releases ?? []).length >= LIMIT}
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
onNext={() => setOffset(offset + LIMIT)}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Tag className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No releases yet</p>
<Button size="sm" className="mt-3" onClick={() => setShowCreate(true)}>
<Plus className="size-3.5" />
Create first release
</Button>
</div>
)}
</div>
</div>
);
}
@@ -151,8 +151,8 @@ export function RepoSettingsPage() {
value={defaultBranch}
onChange={(e) => setDefaultBranch(e.target.value)}
>
{branches.map((b) => (
<option key={b.id} value={b.name}>
{branches.map((b: { name: string }) => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Star } from 'lucide-react';
import { Pagination } from '@/components/repo/Pagination';
import { Spinner } from '@/components/ui/spinner';
import { useRepoStars } from '@/hooks/repo/useRepoStars';
import { timeAgo } from '@/lib/utils';
const LIMIT = 20;
export function RepoStarsPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [offset, setOffset] = useState(0);
const { data: stars, isLoading } = useRepoStars(workspaceName, repoName, LIMIT, offset);
return (
<div className="space-y-4">
<p className="text-[12px] text-muted-foreground">
{(stars ?? []).length} stargazer{(stars ?? []).length !== 1 ? 's' : ''}
</p>
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (stars ?? []).length > 0 ? (
<>
<div className="divide-y divide-border">
{(stars ?? []).map((star) => (
<div
key={star.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
{star.user_id.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-foreground">
{star.user_id}
</span>
<div className="text-[11px] text-muted-foreground">
Starred {timeAgo(star.created_at)}
</div>
</div>
<Star className="size-4 text-amber-500" />
</div>
))}
</div>
<Pagination
offset={offset}
limit={LIMIT}
hasMore={(stars ?? []).length >= LIMIT}
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
onNext={() => setOffset(offset + LIMIT)}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Star className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No stars yet</p>
</div>
)}
</div>
</div>
);
}
+19 -19
View File
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { useRepoTags } from '@/hooks/repo/useRepoTags';
import { timeAgo } from '@/lib/utils';
const LIMIT = 20;
@@ -30,8 +29,8 @@ export function RepoTagsPage() {
workspaceName,
repoName,
requestBody: {
name: newName,
target_commit_sha: newTarget || 'HEAD',
tag_name: newName,
target: newTarget || 'HEAD',
message: newMessage || undefined,
},
}),
@@ -45,13 +44,15 @@ export function RepoTagsPage() {
});
const deleteTag = useMutation({
mutationFn: (tagId: string) => ReposService.repoDeleteTag({ workspaceName, repoName, tagId }),
mutationFn: (tagName: string) =>
ReposService.repoDeleteTag({ workspaceName, repoName, tagName }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] }),
});
const list = tags ?? [];
const filtered = search
? (tags ?? []).filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
: (tags ?? []);
? list.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
: list;
return (
<div className="space-y-4">
@@ -66,7 +67,7 @@ export function RepoTagsPage() {
/>
</div>
<span className="text-[12px] text-muted-foreground">
{(tags ?? []).length} tag{(tags ?? []).length !== 1 ? 's' : ''}
{list.length} tag{list.length !== 1 ? 's' : ''}
</span>
<Button size="sm" className="ml-auto" onClick={() => setShowCreate(!showCreate)}>
<Plus className="size-3.5" />
@@ -84,7 +85,7 @@ export function RepoTagsPage() {
/>
<Input
className="h-8 text-[13px]"
placeholder="Target commit SHA (optional, defaults to HEAD)"
placeholder="Target (branch/commit, defaults to HEAD)"
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
/>
@@ -119,14 +120,14 @@ export function RepoTagsPage() {
<div>
{filtered.map((tag) => (
<div
key={tag.id}
key={tag.name}
className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40"
>
<Tag className="size-4 text-muted-foreground shrink-0" />
<span className="text-[13px] font-semibold text-foreground">{tag.name}</span>
{tag.signed && (
{tag.annotated && (
<span className="rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
signed
annotated
</span>
)}
{tag.message && (
@@ -135,18 +136,17 @@ export function RepoTagsPage() {
</span>
)}
<div className="ml-auto flex items-center gap-2">
<code className="text-[10px] text-muted-foreground/60">
{tag.target_commit_sha.slice(0, 7)}
</code>
<span className="text-[10px] text-muted-foreground/50">
{timeAgo(tag.created_at)}
</span>
{tag.target_oid?.hex && (
<code className="text-[10px] text-muted-foreground/60">
{tag.target_oid.hex.slice(0, 7)}
</code>
)}
<Button
size="icon-sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete tag "${tag.name}"?`)) deleteTag.mutate(tag.id);
if (confirm(`Delete tag "${tag.name}"?`)) deleteTag.mutate(tag.name);
}}
>
<Trash2 className="size-3.5" />
@@ -158,7 +158,7 @@ export function RepoTagsPage() {
<Pagination
offset={offset}
limit={LIMIT}
hasMore={(tags ?? []).length >= LIMIT}
hasMore={list.length >= LIMIT}
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
onNext={() => setOffset(offset + LIMIT)}
/>
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Eye } from 'lucide-react';
import { Pagination } from '@/components/repo/Pagination';
import { Spinner } from '@/components/ui/spinner';
import { useRepoWatchers } from '@/hooks/repo/useRepoWatchers';
import { timeAgo } from '@/lib/utils';
const LIMIT = 20;
export function RepoWatchersPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [offset, setOffset] = useState(0);
const { data: watchers, isLoading } = useRepoWatchers(workspaceName, repoName, LIMIT, offset);
return (
<div className="space-y-4">
<p className="text-[12px] text-muted-foreground">
{(watchers ?? []).length} watcher{(watchers ?? []).length !== 1 ? 's' : ''}
</p>
<div className="overflow-hidden rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (watchers ?? []).length > 0 ? (
<>
<div className="divide-y divide-border">
{(watchers ?? []).map((watch) => (
<div
key={watch.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground">
{watch.user_id.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-foreground">
{watch.user_id}
</span>
<div className="text-[11px] text-muted-foreground">
Watching since {timeAgo(watch.created_at)}
<span className="ml-1 rounded bg-muted px-1 py-0.5 text-[10px]">
{watch.level}
</span>
</div>
</div>
<Eye className="size-4 text-muted-foreground" />
</div>
))}
</div>
<Pagination
offset={offset}
limit={LIMIT}
hasMore={(watchers ?? []).length >= LIMIT}
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
onNext={() => setOffset(offset + LIMIT)}
/>
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Eye className="size-6 text-muted-foreground/30" />
<p className="mt-3 text-[13px] text-muted-foreground">No watchers yet</p>
</div>
)}
</div>
</div>
);
}
@@ -1,19 +1,27 @@
import { ArrowLeft, BookOpen, Clock, FileText, History } from 'lucide-react';
import { ArrowLeft, BookOpen, Clock, FileText, History, Pencil, Save, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useDeleteWikiPage } from '@/hooks/wiki/useDeleteWikiPage';
import { useUpdateWikiPage } from '@/hooks/wiki/useUpdateWikiPage';
import { useWikiPage } from '@/hooks/wiki/useWikiPage';
import { useWikiRevisions } from '@/hooks/wiki/useWikiRevisions';
import { cn, timeAgo } from '@/lib/utils';
export function RepoWikiDetailPage() {
const { workspaceName = '', repoName = '', slug } = useParams();
const navigate = useNavigate();
const { data: page, isLoading, error } = useWikiPage(workspaceName, repoName, slug ?? null);
const { data: revisions } = useWikiRevisions(workspaceName, repoName, slug ?? null);
const updatePage = useUpdateWikiPage(workspaceName, repoName);
const deletePage = useDeleteWikiPage(workspaceName, repoName);
const [activeTab, setActiveTab] = useState<'content' | 'history'>('content');
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
if (isLoading) {
return (
@@ -44,101 +52,197 @@ export function RepoWikiDetailPage() {
);
}
return (
<div className="px-6 pt-6 pb-10">
<Link
to={`/${workspaceName}/repos/${repoName}/wiki`}
className="mb-4 inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3.5" />
Back to wiki
</Link>
const startEditing = () => {
setEditTitle(page.title);
setEditContent(page.content);
setIsEditing(true);
};
<div className="mb-6">
<h1 className="text-xl font-semibold text-foreground">{page.title}</h1>
<div className="mt-2 flex items-center gap-3 text-[12px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
Updated {timeAgo(page.updated_at)}
</span>
<span>Version {page.version}</span>
const cancelEditing = () => {
setIsEditing(false);
setEditTitle('');
setEditContent('');
};
const saveEdit = () => {
if (!editTitle.trim() || !slug) return;
updatePage.mutate(
{
slug,
title: editTitle.trim(),
content: editContent.trim(),
},
{ onSuccess: () => setIsEditing(false) },
);
};
const handleDelete = () => {
if (!slug) return;
if (confirm(`Delete wiki page "${page.title}"?`)) {
deletePage.mutate(slug, {
onSuccess: () => navigate(`/${workspaceName}/repos/${repoName}/wiki`),
});
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Link
to={`/${workspaceName}/repos/${repoName}/wiki`}
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3.5" />
Back to wiki
</Link>
<div className="flex items-center gap-2">
{!isEditing && (
<>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={startEditing}
>
<Pencil className="size-3.5" />
Edit
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-destructive transition-colors hover:bg-destructive/10"
onClick={handleDelete}
disabled={deletePage.isPending}
>
<Trash2 className="size-3.5" />
Delete
</button>
</>
)}
</div>
</div>
<div className="mb-4 flex items-center gap-1 border-b border-border">
<button
type="button"
onClick={() => setActiveTab('content')}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors',
activeTab === 'content'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<FileText className="size-3.5" />
Content
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors',
activeTab === 'history'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<History className="size-3.5" />
History
{revisions && revisions.length > 0 && (
<span className="ml-1 rounded-full bg-muted px-2 py-0.5 text-[11px]">
{revisions.length}
</span>
)}
</button>
</div>
{activeTab === 'content' && (
<div className="rounded-xl border border-border bg-card p-5">
<div className="prose prose-sm max-w-none text-[14px] text-foreground whitespace-pre-wrap">
{page.content}
{isEditing ? (
<div className="space-y-3">
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[16px] font-semibold text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={20}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 text-[14px] font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!editTitle.trim() || updatePage.isPending}
onClick={saveEdit}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-[5px] text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/85 disabled:opacity-50"
>
<Save className="size-3.5" />
{updatePage.isPending ? 'Saving...' : 'Save changes'}
</button>
<button
type="button"
onClick={cancelEditing}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
>
<X className="size-3.5" />
Cancel
</button>
</div>
</div>
)}
) : (
<>
<div>
<h1 className="text-xl font-semibold text-foreground">{page.title}</h1>
<div className="mt-2 flex items-center gap-3 text-[12px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
Updated {timeAgo(page.updated_at)}
</span>
<span>Version {page.version}</span>
</div>
</div>
{activeTab === 'history' && (
<div className="space-y-3">
{revisions && revisions.length > 0 ? (
revisions.map((revision) => (
<div
key={revision.id}
className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3"
>
<History className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">
Version {revision.version}
</span>
<span className="text-[12px] text-muted-foreground">
{timeAgo(revision.created_at)}
</span>
</div>
{revision.commit_message && (
<p className="mt-0.5 text-[12px] text-muted-foreground">
{revision.commit_message}
</p>
)}
</div>
<div className="flex items-center gap-1 border-b border-border">
<button
type="button"
onClick={() => setActiveTab('content')}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors',
activeTab === 'content'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<FileText className="size-3.5" />
Content
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] font-medium transition-colors',
activeTab === 'history'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<History className="size-3.5" />
History
{revisions && revisions.length > 0 && (
<span className="ml-1 rounded-full bg-muted px-2 py-0.5 text-[11px]">
{revisions.length}
</span>
)}
</button>
</div>
{activeTab === 'content' && (
<div className="rounded-xl border border-border bg-card p-5">
<div className="prose prose-sm max-w-none text-[14px] text-foreground whitespace-pre-wrap">
{page.content}
</div>
))
) : (
<div className="py-8 text-center text-[13px] text-muted-foreground">
No revision history available
</div>
)}
</div>
{activeTab === 'history' && (
<div className="space-y-3">
{revisions && revisions.length > 0 ? (
revisions.map((revision) => (
<div
key={revision.id}
className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3"
>
<History className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">
Version {revision.version}
</span>
<span className="text-[12px] text-muted-foreground">
{timeAgo(revision.created_at)}
</span>
</div>
{revision.commit_message && (
<p className="mt-0.5 text-[12px] text-muted-foreground">
{revision.commit_message}
</p>
)}
</div>
</div>
))
) : (
<div className="py-8 text-center text-[13px] text-muted-foreground">
No revision history available
</div>
)}
</div>
)}
</>
)}
</div>
);
+70 -3
View File
@@ -2,16 +2,36 @@ import { BookOpen, Clock, FileText, Plus, Search } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { useCreateWikiPage } from '@/hooks/wiki/useCreateWikiPage';
import { useWikiPages } from '@/hooks/wiki/useWikiPages';
import { timeAgo } from '@/lib/utils';
export function RepoWikiPage() {
const { workspaceName = '', repoName = '' } = useParams();
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newContent, setNewContent] = useState('');
const { data: pages, isLoading } = useWikiPages(workspaceName, repoName, search || null);
const createPage = useCreateWikiPage(workspaceName, repoName);
const list = pages ?? [];
const handleCreate = () => {
if (!newTitle.trim()) return;
createPage.mutate(
{ title: newTitle.trim(), content: newContent.trim() },
{
onSuccess: () => {
setNewTitle('');
setNewContent('');
setShowCreate(false);
},
},
);
};
return (
<div>
<div className="mb-4 flex items-center justify-between">
@@ -28,12 +48,49 @@ export function RepoWikiPage() {
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-[5px] text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/85"
onClick={() => setShowCreate(!showCreate)}
>
<Plus className="size-3.5" />
New page
</button>
</div>
{showCreate && (
<div className="mb-4 space-y-3 rounded-lg border border-border p-4">
<input
type="text"
placeholder="Page title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[14px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<textarea
placeholder="Page content..."
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
rows={6}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-[14px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex gap-2">
<button
type="button"
disabled={!newTitle.trim() || createPage.isPending}
onClick={handleCreate}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-[5px] text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/85 disabled:opacity-50"
>
{createPage.isPending ? 'Creating...' : 'Create page'}
</button>
<button
type="button"
onClick={() => setShowCreate(false)}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-[5px] text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
>
Cancel
</button>
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center gap-2 py-12 text-[14px] text-muted-foreground">
<Spinner />
@@ -45,9 +102,19 @@ export function RepoWikiPage() {
{search ? 'No pages match your search' : 'No wiki pages yet'}
</p>
{!search && (
<p className="mt-1 text-[13px] text-muted-foreground">
Create the first page to get started
</p>
<>
<p className="mt-1 text-[13px] text-muted-foreground">
Create the first page to get started
</p>
<button
type="button"
className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-[5px] text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/85"
onClick={() => setShowCreate(true)}
>
<Plus className="size-3.5" />
Create first page
</button>
</>
)}
</div>
) : (
+23 -1
View File
@@ -10,13 +10,24 @@ import { RootLayout } from '@/pages/RootLayout';
import { IssueDetailPage } from '@/pages/Workspace/Issues/IssueDetailPage';
import { WorkspaceIssuesPage } from '@/pages/Workspace/Issues/WorkspaceIssuesPage';
import { WorkspaceMembersPage } from '@/pages/Workspace/Members/WorkspaceMembersPage';
import { RepoBlobPage } from '@/pages/Workspace/Repo/RepoBlobPage';
import { RepoBranchesPage } from '@/pages/Workspace/Repo/RepoBranchesPage';
import { RepoCodePage } from '@/pages/Workspace/Repo/RepoCodePage';
import { RepoCommitDetailPage } from '@/pages/Workspace/Repo/RepoCommitDetailPage';
import { RepoCommitsPage } from '@/pages/Workspace/Repo/RepoCommitsPage';
import { RepoCreatePrPage } from '@/pages/Workspace/Repo/RepoCreatePrPage';
import { RepoDeployKeysPage } from '@/pages/Workspace/Repo/RepoDeployKeysPage';
import { RepoForksPage } from '@/pages/Workspace/Repo/RepoForksPage';
import { RepoLayout } from '@/pages/Workspace/Repo/RepoLayout';
import { RepoMembersPage } from '@/pages/Workspace/Repo/RepoMembersPage';
import { RepoOverviewPage } from '@/pages/Workspace/Repo/RepoOverviewPage';
import { RepoPrDetailPage } from '@/pages/Workspace/Repo/RepoPrDetailPage';
import { RepoPullsPage } from '@/pages/Workspace/Repo/RepoPullsPage';
import { RepoReleasesPage } from '@/pages/Workspace/Repo/RepoReleasesPage';
import { RepoSettingsPage } from '@/pages/Workspace/Repo/RepoSettingsPage';
import { RepoStarsPage } from '@/pages/Workspace/Repo/RepoStarsPage';
import { RepoTagsPage } from '@/pages/Workspace/Repo/RepoTagsPage';
import { RepoWatchersPage } from '@/pages/Workspace/Repo/RepoWatchersPage';
import { RepoWebhooksPage } from '@/pages/Workspace/Repo/RepoWebhooksPage';
import { RepoWikiDetailPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiDetailPage';
import { RepoWikiPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiPage';
@@ -41,15 +52,26 @@ export const router = createBrowserRouter([
path: 'repos/:repoName',
Component: RepoLayout,
children: [
{ index: true, Component: RepoOverviewPage },
{ index: true, Component: RepoCodePage },
{ path: 'overview', Component: RepoOverviewPage },
{ path: 'branches', Component: RepoBranchesPage },
{ path: 'tags', Component: RepoTagsPage },
{ path: 'releases', Component: RepoReleasesPage },
{ path: 'commits', Component: RepoCommitsPage },
{ path: 'commits/:commitSha', Component: RepoCommitDetailPage },
{ path: 'pulls', Component: RepoPullsPage },
{ path: 'pulls/new', Component: RepoCreatePrPage },
{ path: 'pulls/:prNumber', Component: RepoPrDetailPage },
{ path: 'members', Component: RepoMembersPage },
{ path: 'forks', Component: RepoForksPage },
{ path: 'stars', Component: RepoStarsPage },
{ path: 'watchers', Component: RepoWatchersPage },
{ path: 'deploy-keys', Component: RepoDeployKeysPage },
{ path: 'webhooks', Component: RepoWebhooksPage },
{ path: 'wiki', Component: RepoWikiPage },
{ path: 'wiki/:slug', Component: RepoWikiDetailPage },
{ path: 'settings', Component: RepoSettingsPage },
{ path: 'blob', Component: RepoBlobPage },
],
},
{ path: 'issues', Component: WorkspaceIssuesPage },