feat: add repo and PR management features with ws client
This commit is contained in:
@@ -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": {
|
"/api/v1/im/workspaces/{workspace_name}/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"ApiResponse_bool": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -55495,6 +55564,25 @@
|
|||||||
"format": "uuid"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export type { ApiResponse_WorkspacePendingApproval } from './models/ApiResponse_
|
|||||||
export type { ApiResponse_WorkspaceSettings } from './models/ApiResponse_WorkspaceSettings';
|
export type { ApiResponse_WorkspaceSettings } from './models/ApiResponse_WorkspaceSettings';
|
||||||
export type { ApiResponse_WorkspaceStats } from './models/ApiResponse_WorkspaceStats';
|
export type { ApiResponse_WorkspaceStats } from './models/ApiResponse_WorkspaceStats';
|
||||||
export type { ApiResponse_WorkspaceWebhook } from './models/ApiResponse_WorkspaceWebhook';
|
export type { ApiResponse_WorkspaceWebhook } from './models/ApiResponse_WorkspaceWebhook';
|
||||||
|
export type { ApiResponse_WsTokenResponse } from './models/ApiResponse_WsTokenResponse';
|
||||||
export type { AvatarData } from './models/AvatarData';
|
export type { AvatarData } from './models/AvatarData';
|
||||||
export type { BlameHunk } from './models/BlameHunk';
|
export type { BlameHunk } from './models/BlameHunk';
|
||||||
export type { BlameLine } from './models/BlameLine';
|
export type { BlameLine } from './models/BlameLine';
|
||||||
@@ -496,6 +497,7 @@ export type { WorkspacePendingApproval } from './models/WorkspacePendingApproval
|
|||||||
export type { WorkspaceSettings } from './models/WorkspaceSettings';
|
export type { WorkspaceSettings } from './models/WorkspaceSettings';
|
||||||
export type { WorkspaceStats } from './models/WorkspaceStats';
|
export type { WorkspaceStats } from './models/WorkspaceStats';
|
||||||
export type { WorkspaceWebhook } from './models/WorkspaceWebhook';
|
export type { WorkspaceWebhook } from './models/WorkspaceWebhook';
|
||||||
|
export type { WsTokenResponse } from './models/WsTokenResponse';
|
||||||
|
|
||||||
export { $AcceptInvitationParams } from './schemas/$AcceptInvitationParams';
|
export { $AcceptInvitationParams } from './schemas/$AcceptInvitationParams';
|
||||||
export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest';
|
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_WorkspaceSettings } from './schemas/$ApiResponse_WorkspaceSettings';
|
||||||
export { $ApiResponse_WorkspaceStats } from './schemas/$ApiResponse_WorkspaceStats';
|
export { $ApiResponse_WorkspaceStats } from './schemas/$ApiResponse_WorkspaceStats';
|
||||||
export { $ApiResponse_WorkspaceWebhook } from './schemas/$ApiResponse_WorkspaceWebhook';
|
export { $ApiResponse_WorkspaceWebhook } from './schemas/$ApiResponse_WorkspaceWebhook';
|
||||||
|
export { $ApiResponse_WsTokenResponse } from './schemas/$ApiResponse_WsTokenResponse';
|
||||||
export { $AvatarData } from './schemas/$AvatarData';
|
export { $AvatarData } from './schemas/$AvatarData';
|
||||||
export { $BlameHunk } from './schemas/$BlameHunk';
|
export { $BlameHunk } from './schemas/$BlameHunk';
|
||||||
export { $BlameLine } from './schemas/$BlameLine';
|
export { $BlameLine } from './schemas/$BlameLine';
|
||||||
@@ -986,6 +989,7 @@ export { $WorkspacePendingApproval } from './schemas/$WorkspacePendingApproval';
|
|||||||
export { $WorkspaceSettings } from './schemas/$WorkspaceSettings';
|
export { $WorkspaceSettings } from './schemas/$WorkspaceSettings';
|
||||||
export { $WorkspaceStats } from './schemas/$WorkspaceStats';
|
export { $WorkspaceStats } from './schemas/$WorkspaceStats';
|
||||||
export { $WorkspaceWebhook } from './schemas/$WorkspaceWebhook';
|
export { $WorkspaceWebhook } from './schemas/$WorkspaceWebhook';
|
||||||
|
export { $WsTokenResponse } from './schemas/$WsTokenResponse';
|
||||||
|
|
||||||
export { AuthService } from './services/AuthService';
|
export { AuthService } from './services/AuthService';
|
||||||
export { GitService } from './services/GitService';
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -12,6 +12,7 @@ import type { ApiResponse_Regenerate2FABackupCodesResponse } from '../models/Api
|
|||||||
import type { ApiResponse_RegisterEmailCodeResponse } from '../models/ApiResponse_RegisterEmailCodeResponse';
|
import type { ApiResponse_RegisterEmailCodeResponse } from '../models/ApiResponse_RegisterEmailCodeResponse';
|
||||||
import type { ApiResponse_RegisterResponse } from '../models/ApiResponse_RegisterResponse';
|
import type { ApiResponse_RegisterResponse } from '../models/ApiResponse_RegisterResponse';
|
||||||
import type { ApiResponse_RsaResponse } from '../models/ApiResponse_RsaResponse';
|
import type { ApiResponse_RsaResponse } from '../models/ApiResponse_RsaResponse';
|
||||||
|
import type { ApiResponse_WsTokenResponse } from '../models/ApiResponse_WsTokenResponse';
|
||||||
import type { ChangePasswordParams } from '../models/ChangePasswordParams';
|
import type { ChangePasswordParams } from '../models/ChangePasswordParams';
|
||||||
import type { Disable2FAParams } from '../models/Disable2FAParams';
|
import type { Disable2FAParams } from '../models/Disable2FAParams';
|
||||||
import type { EmailChangeRequest } from '../models/EmailChangeRequest';
|
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.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,46 +34,74 @@ export { useAssignPr } from './pull-request/useAssignPr';
|
|||||||
export { useAssignPrLabel } from './pull-request/useAssignPrLabel';
|
export { useAssignPrLabel } from './pull-request/useAssignPrLabel';
|
||||||
export { useClosePr } from './pull-request/useClosePr';
|
export { useClosePr } from './pull-request/useClosePr';
|
||||||
export { useCreatePr } from './pull-request/useCreatePr';
|
export { useCreatePr } from './pull-request/useCreatePr';
|
||||||
|
export { useCreateReview } from './pull-request/useCreateReview';
|
||||||
export { useMergePr } from './pull-request/useMergePr';
|
export { useMergePr } from './pull-request/useMergePr';
|
||||||
export { usePrAssignees } from './pull-request/usePrAssignees';
|
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 { usePrDetail } from './pull-request/usePrDetail';
|
||||||
|
export { usePrEvents } from './pull-request/usePrEvents';
|
||||||
export { usePrFiles } from './pull-request/usePrFiles';
|
export { usePrFiles } from './pull-request/usePrFiles';
|
||||||
export { usePrLabelRelations } from './pull-request/usePrLabelRelations';
|
export { usePrLabelRelations } from './pull-request/usePrLabelRelations';
|
||||||
export { usePrLabels } from './pull-request/usePrLabels';
|
export { usePrLabels } from './pull-request/usePrLabels';
|
||||||
export { usePrList } from './pull-request/usePrList';
|
export { usePrList } from './pull-request/usePrList';
|
||||||
export { usePrReactions } from './pull-request/usePrReactions';
|
export { usePrReactions } from './pull-request/usePrReactions';
|
||||||
export { usePrReviews } from './pull-request/usePrReviews';
|
export { usePrReviews } from './pull-request/usePrReviews';
|
||||||
|
export { usePrStatus } from './pull-request/usePrStatus';
|
||||||
export { useRemovePrReaction } from './pull-request/useRemovePrReaction';
|
export { useRemovePrReaction } from './pull-request/useRemovePrReaction';
|
||||||
export { useReopenPr } from './pull-request/useReopenPr';
|
export { useReopenPr } from './pull-request/useReopenPr';
|
||||||
|
export { useSubmitReview } from './pull-request/useSubmitReview';
|
||||||
export { useUnassignPr } from './pull-request/useUnassignPr';
|
export { useUnassignPr } from './pull-request/useUnassignPr';
|
||||||
export { useUnassignPrLabel } from './pull-request/useUnassignPrLabel';
|
export { useUnassignPrLabel } from './pull-request/useUnassignPrLabel';
|
||||||
export { useUpdatePr } from './pull-request/useUpdatePr';
|
export { useUpdatePr } from './pull-request/useUpdatePr';
|
||||||
// repo
|
// repo
|
||||||
|
export { useAddDeployKey } from './repo/useAddDeployKey';
|
||||||
|
export { useAddRepoMember } from './repo/useAddRepoMember';
|
||||||
export { useArchiveRepo } from './repo/useArchiveRepo';
|
export { useArchiveRepo } from './repo/useArchiveRepo';
|
||||||
export { useCreateBranch } from './repo/useCreateBranch';
|
export { useCreateBranch } from './repo/useCreateBranch';
|
||||||
export { useCreateProtectionRule } from './repo/useCreateProtectionRule';
|
export { useCreateProtectionRule } from './repo/useCreateProtectionRule';
|
||||||
|
export { useCreateRelease } from './repo/useCreateRelease';
|
||||||
export { useCreateRepo } from './repo/useCreateRepo';
|
export { useCreateRepo } from './repo/useCreateRepo';
|
||||||
export { useCreateTag } from './repo/useCreateTag';
|
export { useCreateTag } from './repo/useCreateTag';
|
||||||
export { useCreateWebhook } from './repo/useCreateWebhook';
|
export { useCreateWebhook } from './repo/useCreateWebhook';
|
||||||
export { useDeleteBranch } from './repo/useDeleteBranch';
|
export { useDeleteBranch } from './repo/useDeleteBranch';
|
||||||
|
export { useDeleteDeployKey } from './repo/useDeleteDeployKey';
|
||||||
export { useDeleteProtectionRule } from './repo/useDeleteProtectionRule';
|
export { useDeleteProtectionRule } from './repo/useDeleteProtectionRule';
|
||||||
|
export { useDeleteRelease } from './repo/useDeleteRelease';
|
||||||
export { useDeleteRepo } from './repo/useDeleteRepo';
|
export { useDeleteRepo } from './repo/useDeleteRepo';
|
||||||
export { useDeleteTag } from './repo/useDeleteTag';
|
export { useDeleteTag } from './repo/useDeleteTag';
|
||||||
export { useDeleteWebhook } from './repo/useDeleteWebhook';
|
export { useDeleteWebhook } from './repo/useDeleteWebhook';
|
||||||
export { useForkRepo } from './repo/useForkRepo';
|
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 { useRepo } from './repo/useRepo';
|
||||||
export { useRepoBranches } from './repo/useRepoBranches';
|
export { useRepoBranches } from './repo/useRepoBranches';
|
||||||
|
export { useRepoDeployKeys } from './repo/useRepoDeployKeys';
|
||||||
export { useRepoForks } from './repo/useRepoForks';
|
export { useRepoForks } from './repo/useRepoForks';
|
||||||
export { useRepoInvitations } from './repo/useRepoInvitations';
|
export { useRepoInvitations } from './repo/useRepoInvitations';
|
||||||
export { useRepoMembers } from './repo/useRepoMembers';
|
export { useRepoMembers } from './repo/useRepoMembers';
|
||||||
export { useRepoProtectionRules } from './repo/useRepoProtectionRules';
|
export { useRepoProtectionRules } from './repo/useRepoProtectionRules';
|
||||||
export { useRepoPulls } from './repo/useRepoPulls';
|
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 { useRepoStats } from './repo/useRepoStats';
|
||||||
export { useRepoTags } from './repo/useRepoTags';
|
export { useRepoTags } from './repo/useRepoTags';
|
||||||
|
export { useRepoWatchers } from './repo/useRepoWatchers';
|
||||||
export { useRepoWebhooks } from './repo/useRepoWebhooks';
|
export { useRepoWebhooks } from './repo/useRepoWebhooks';
|
||||||
export { useSetDefaultBranch } from './repo/useSetDefaultBranch';
|
export { useSetDefaultBranch } from './repo/useSetDefaultBranch';
|
||||||
|
export { useStarRepo, useUnstarRepo } from './repo/useStarRepo';
|
||||||
export { useTransferRepo } from './repo/useTransferRepo';
|
export { useTransferRepo } from './repo/useTransferRepo';
|
||||||
|
export { useUpdateRelease } from './repo/useUpdateRelease';
|
||||||
export { useUpdateRepo } from './repo/useUpdateRepo';
|
export { useUpdateRepo } from './repo/useUpdateRepo';
|
||||||
|
export { useUpdateRepoMemberRole } from './repo/useUpdateRepoMemberRole';
|
||||||
|
export { useWatchRepo, useUnwatchRepo } from './repo/useWatchRepo';
|
||||||
// user
|
// user
|
||||||
export { useAccessTokens } from './user/useAccessTokens';
|
export { useAccessTokens } from './user/useAccessTokens';
|
||||||
export { useCurrentUser } from './user/useCurrentUser';
|
export { useCurrentUser } from './user/useCurrentUser';
|
||||||
@@ -89,6 +117,9 @@ export { useUserNotifications } from './user/useUserNotifications';
|
|||||||
export { useUserProfile } from './user/useUserProfile';
|
export { useUserProfile } from './user/useUserProfile';
|
||||||
export { useUserSessions } from './user/useUserSessions';
|
export { useUserSessions } from './user/useUserSessions';
|
||||||
// wiki
|
// wiki
|
||||||
|
export { useCreateWikiPage } from './wiki/useCreateWikiPage';
|
||||||
|
export { useDeleteWikiPage } from './wiki/useDeleteWikiPage';
|
||||||
|
export { useUpdateWikiPage } from './wiki/useUpdateWikiPage';
|
||||||
export { useWikiPage } from './wiki/useWikiPage';
|
export { useWikiPage } from './wiki/useWikiPage';
|
||||||
export { useWikiPages } from './wiki/useWikiPages';
|
export { useWikiPages } from './wiki/useWikiPages';
|
||||||
export { useWikiRevisions } from './wiki/useWikiRevisions';
|
export { useWikiRevisions } from './wiki/useWikiRevisions';
|
||||||
|
|||||||
@@ -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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export function useCreateBranch() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (params: { name: string; commit_sha: string }) =>
|
mutationFn: (params: { branch_name: string; start_point: string }) =>
|
||||||
ReposService.repoCreateBranch({ workspaceName, repoName, requestBody: params }),
|
ReposService.repoCreateBranch({ workspaceName, repoName, requestBody: params }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export function useCreateTag() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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 }),
|
ReposService.repoCreateTag({ workspaceName, repoName, requestBody: params }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export function useDeleteBranch() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (branchId: string) =>
|
mutationFn: (branchName: string) =>
|
||||||
ReposService.repoDeleteBranch({ workspaceName, repoName, branchId }),
|
ReposService.repoDeleteBranch({ workspaceName, repoName, branchName }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export function useDeleteTag() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (tagId: string) => ReposService.repoDeleteTag({ workspaceName, repoName, tagId }),
|
mutationFn: (tagName: string) => ReposService.repoDeleteTag({ workspaceName, repoName, tagName }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export function useRepoBranches(workspaceName: string | null, repoName: string |
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (!workspaceName || !repoName) throw new Error('Missing params');
|
if (!workspaceName || !repoName) throw new Error('Missing params');
|
||||||
return ReposService.repoListBranches({ workspaceName, repoName, limit: 100 }).then(
|
return ReposService.repoListBranches({ workspaceName, repoName, limit: 100 }).then(
|
||||||
(r) => r.data,
|
(r) => r.data.branches,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!workspaceName && !!repoName,
|
enabled: !!workspaceName && !!repoName,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function useRepoTags(
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
if (!workspaceName || !repoName) throw new Error('Missing params');
|
if (!workspaceName || !repoName) throw new Error('Missing params');
|
||||||
return ReposService.repoListTags({ workspaceName, repoName, offset, limit }).then(
|
return ReposService.repoListTags({ workspaceName, repoName, offset, limit }).then(
|
||||||
(r) => r.data,
|
(r) => r.data.tags,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: !!workspaceName && !!repoName,
|
enabled: !!workspaceName && !!repoName,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ export function useSetDefaultBranch() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (branchId: string) =>
|
mutationFn: (branchName: string) =>
|
||||||
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchId }),
|
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchName }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,6 +5,8 @@ import { RouterProvider } from 'react-router-dom';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { UserProvider } from '@/contexts/UserContext';
|
import { UserProvider } from '@/contexts/UserContext';
|
||||||
|
import { SocketProvider } from '@/socket';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
import { router } from './routes';
|
import { router } from './routes';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -32,7 +34,9 @@ createRoot(doc).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
<SocketProvider imksUrl={env.IMKS_URL}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</SocketProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const env = {
|
export const env = {
|
||||||
API_BASE_URL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
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,
|
DEV: import.meta.env.DEV,
|
||||||
MODE: import.meta.env.MODE,
|
MODE: import.meta.env.MODE,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { ReposService } from '@/client/services/ReposService';
|
import { ReposService } from '@/client/services/ReposService';
|
||||||
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
|
import { useRepoBranches } from '@/hooks/repo/useRepoBranches';
|
||||||
import { cn, timeAgo } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function RepoBranchesPage() {
|
export function RepoBranchesPage() {
|
||||||
const { workspaceName = '', repoName = '' } = useParams();
|
const { workspaceName = '', repoName = '' } = useParams();
|
||||||
@@ -24,7 +24,7 @@ export function RepoBranchesPage() {
|
|||||||
ReposService.repoCreateBranch({
|
ReposService.repoCreateBranch({
|
||||||
workspaceName,
|
workspaceName,
|
||||||
repoName,
|
repoName,
|
||||||
requestBody: { name: newName, commit_sha: newSource || 'HEAD' },
|
requestBody: { branch_name: newName, start_point: newSource || 'HEAD' },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewName('');
|
setNewName('');
|
||||||
@@ -35,27 +35,28 @@ export function RepoBranchesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteBranch = useMutation({
|
const deleteBranch = useMutation({
|
||||||
mutationFn: (branchId: string) =>
|
mutationFn: (branchName: string) =>
|
||||||
ReposService.repoDeleteBranch({ workspaceName, repoName, branchId }),
|
ReposService.repoDeleteBranch({ workspaceName, repoName, branchName }),
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] }),
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setDefault = useMutation({
|
const setDefault = useMutation({
|
||||||
mutationFn: (branchId: string) =>
|
mutationFn: (branchName: string) =>
|
||||||
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchId }),
|
ReposService.repoSetDefaultBranch({ workspaceName, repoName, branchName }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'branches'] });
|
||||||
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
|
qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const list = branches ?? [];
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? (branches ?? []).filter((b) => b.name.toLowerCase().includes(search.toLowerCase()))
|
? list.filter((b) => b.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
: (branches ?? []);
|
: list;
|
||||||
|
|
||||||
const defaultBranches = filtered.filter((b) => b.default_branch);
|
const defaultBranches = filtered.filter((b) => b.is_default);
|
||||||
const otherBranches = filtered.filter((b) => !b.default_branch);
|
const otherBranches = filtered.filter((b) => !b.is_default);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -85,7 +86,7 @@ export function RepoBranchesPage() {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 w-40 text-[13px]"
|
className="h-8 w-40 text-[13px]"
|
||||||
placeholder="Source (commit SHA)"
|
placeholder="Source (branch/commit)"
|
||||||
value={newSource}
|
value={newSource}
|
||||||
onChange={(e) => setNewSource(e.target.value)}
|
onChange={(e) => setNewSource(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -111,7 +112,7 @@ export function RepoBranchesPage() {
|
|||||||
<div>
|
<div>
|
||||||
{defaultBranches.map((b) => (
|
{defaultBranches.map((b) => (
|
||||||
<BranchRow
|
<BranchRow
|
||||||
key={b.id}
|
key={b.name}
|
||||||
branch={b}
|
branch={b}
|
||||||
isDefault
|
isDefault
|
||||||
onDelete={deleteBranch}
|
onDelete={deleteBranch}
|
||||||
@@ -126,7 +127,7 @@ export function RepoBranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{otherBranches.map((b) => (
|
{otherBranches.map((b) => (
|
||||||
<BranchRow key={b.id} branch={b} onDelete={deleteBranch} onSetDefault={setDefault} />
|
<BranchRow key={b.name} branch={b} onDelete={deleteBranch} onSetDefault={setDefault} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -144,12 +145,10 @@ export function RepoBranchesPage() {
|
|||||||
|
|
||||||
interface BranchRowProps {
|
interface BranchRowProps {
|
||||||
branch: {
|
branch: {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
commit_sha: string;
|
commit?: { abbreviated_oid?: string } | null;
|
||||||
default_branch: boolean;
|
is_default?: boolean;
|
||||||
protected: boolean;
|
is_merged?: boolean;
|
||||||
last_push_at?: string | null;
|
|
||||||
};
|
};
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
onDelete: UseMutationResult<unknown, unknown, string>;
|
onDelete: UseMutationResult<unknown, unknown, string>;
|
||||||
@@ -157,37 +156,36 @@ interface BranchRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BranchRow({ branch, isDefault, onDelete, onSetDefault }: BranchRowProps) {
|
function BranchRow({ branch, isDefault, onDelete, onSetDefault }: BranchRowProps) {
|
||||||
|
const commitSha = branch.commit?.abbreviated_oid ?? '';
|
||||||
|
const isDef = isDefault || branch.is_default;
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<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>
|
<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">
|
<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" />
|
<Star className="size-2.5" />
|
||||||
Default
|
Default
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{branch.protected && (
|
{branch.is_merged && (
|
||||||
<span className="inline-flex items-center gap-0.5 rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
<Shield className="size-2.5" />
|
Merged
|
||||||
Protected
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<code className="ml-auto text-[10px] text-muted-foreground/60">
|
{commitSha && (
|
||||||
{branch.commit_sha.slice(0, 7)}
|
<code className="ml-auto text-[10px] text-muted-foreground/60">{commitSha.slice(0, 7)}</code>
|
||||||
</code>
|
|
||||||
{branch.last_push_at && (
|
|
||||||
<span className="text-[10px] text-muted-foreground/50">{timeAgo(branch.last_push_at)}</span>
|
|
||||||
)}
|
)}
|
||||||
{!isDefault && (
|
{!isDef && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => onSetDefault.mutate(branch.id)}
|
onClick={() => onSetDefault.mutate(branch.name)}
|
||||||
title="Set as default branch"
|
title="Set as default branch"
|
||||||
>
|
>
|
||||||
<Star className="size-3" />
|
<Star className="size-3" />
|
||||||
@@ -197,7 +195,7 @@ function BranchRow({ branch, isDefault, onDelete, onSetDefault }: BranchRowProps
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => {
|
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" />
|
<Trash2 className="size-3.5" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,34 +2,35 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Code,
|
Code,
|
||||||
|
Eye,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitCommit,
|
||||||
|
GitFork,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Globe,
|
Globe,
|
||||||
|
Key,
|
||||||
Lock,
|
Lock,
|
||||||
Settings,
|
Settings,
|
||||||
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
|
Users,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useRepo } from '@/hooks/repo/useRepo';
|
import { useRepo } from '@/hooks/repo/useRepo';
|
||||||
|
import { useRepoStats } from '@/hooks/repo/useRepoStats';
|
||||||
|
import { useStarRepo, useUnstarRepo } from '@/hooks/repo/useStarRepo';
|
||||||
import { cn } from '@/lib/utils';
|
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() {
|
export function RepoLayout() {
|
||||||
const { workspaceName = '', repoName = '' } = useParams();
|
const { workspaceName = '', repoName = '' } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: repo, isLoading, error } = useRepo(workspaceName, repoName);
|
const { data: repo, isLoading, error } = useRepo(workspaceName, repoName);
|
||||||
|
const { data: stats } = useRepoStats(workspaceName, repoName);
|
||||||
|
const starRepo = useStarRepo();
|
||||||
|
const unstarRepo = useUnstarRepo();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +64,29 @@ export function RepoLayout() {
|
|||||||
|
|
||||||
const base = `/${workspaceName}/repos/${repoName}`;
|
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 (
|
return (
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -76,9 +100,34 @@ export function RepoLayout() {
|
|||||||
)}
|
)}
|
||||||
{repo.is_fork && (
|
{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">
|
<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
|
Fork
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{repo.description && (
|
{repo.description && (
|
||||||
@@ -96,7 +145,7 @@ export function RepoLayout() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to === '' ? base : `${base}/${tab.to}`}
|
to={tab.to === '' ? base : `${base}/${tab.to}`}
|
||||||
end={tab.end}
|
end={'end' in tab && tab.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors',
|
'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.icon className="size-3.5" />
|
||||||
{tab.label}
|
{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>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
|
||||||
Eye,
|
Eye,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitCommit,
|
||||||
GitFork,
|
GitFork,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Key,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
|
Users,
|
||||||
|
Webhook,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useRepo } from '@/hooks/repo/useRepo';
|
import { useRepo } from '@/hooks/repo/useRepo';
|
||||||
import { useRepoStats } from '@/hooks/repo/useRepoStats';
|
import { useRepoStats } from '@/hooks/repo/useRepoStats';
|
||||||
|
import { timeAgo } from '@/lib/utils';
|
||||||
|
|
||||||
export function RepoOverviewPage() {
|
export function RepoOverviewPage() {
|
||||||
const { workspaceName = '', repoName = '' } = useParams();
|
const { workspaceName = '', repoName = '' } = useParams();
|
||||||
@@ -27,25 +31,29 @@ export function RepoOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base = `/${workspaceName}/repos/${repoName}`;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ icon: GitBranch, label: 'Branches', value: stats?.branches_count ?? 0 },
|
{ icon: GitBranch, label: 'Branches', value: stats?.branches_count ?? 0, to: `${base}/branches` },
|
||||||
{ icon: Tag, label: 'Tags', value: stats?.tags_count ?? 0 },
|
{ icon: Tag, label: 'Tags', value: stats?.tags_count ?? 0, to: `${base}/tags` },
|
||||||
{ icon: BookOpen, label: 'Commits', value: stats?.commits_count ?? 0 },
|
{ icon: Tag, label: 'Releases', value: stats?.releases_count ?? 0, to: `${base}/releases` },
|
||||||
{ icon: Star, label: 'Stars', value: stats?.stars_count ?? 0 },
|
{ icon: GitCommit, label: 'Commits', value: stats?.commits_count ?? 0, to: `${base}/commits` },
|
||||||
{ icon: Eye, label: 'Watchers', value: stats?.watchers_count ?? 0 },
|
{ icon: Star, label: 'Stars', value: stats?.stars_count ?? 0, to: `${base}/stars` },
|
||||||
{ icon: GitFork, label: 'Forks', value: stats?.forks_count ?? 0 },
|
{ icon: Eye, label: 'Watchers', value: stats?.watchers_count ?? 0, to: `${base}/watchers` },
|
||||||
{ icon: GitPullRequest, label: 'Open PRs', value: stats?.open_pull_requests_count ?? 0 },
|
{ icon: GitFork, label: 'Forks', value: stats?.forks_count ?? 0, to: `${base}/forks` },
|
||||||
{ icon: HardDrive, label: 'Size', value: formatSize(stats?.size_bytes ?? 0) },
|
{ 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => {
|
||||||
<div
|
const content = (
|
||||||
key={card.label}
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-muted-foreground/30">
|
||||||
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">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
<card.icon className="size-4 text-muted-foreground" />
|
<card.icon className="size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +62,16 @@ export function RepoOverviewPage() {
|
|||||||
<p className="text-[11px] text-muted-foreground">{card.label}</p>
|
<p className="text-[11px] text-muted-foreground">{card.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
|
||||||
|
return card.to ? (
|
||||||
|
<Link key={card.label} to={card.to}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={card.label}>{content}</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card p-5">
|
<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]">
|
<dl className="mt-3 space-y-2 text-[13px]">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-28 shrink-0 text-muted-foreground">Default branch</dt>
|
<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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-28 shrink-0 text-muted-foreground">Visibility</dt>
|
<dt className="w-28 shrink-0 text-muted-foreground">Visibility</dt>
|
||||||
@@ -74,17 +98,30 @@ export function RepoOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-28 shrink-0 text-muted-foreground">Created</dt>
|
<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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-28 shrink-0 text-muted-foreground">Updated</dt>
|
<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>
|
</div>
|
||||||
{stats?.last_push_at && (
|
{stats?.last_push_at && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-28 shrink-0 text-muted-foreground">Last push</dt>
|
<dt className="w-28 shrink-0 text-muted-foreground">Last push</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">{timeAgo(stats.last_push_at)}</dd>
|
||||||
{new Date(stats.last_push_at).toLocaleDateString()}
|
</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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,22 +11,32 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from '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 { 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 { usePrDetail } from '@/hooks/pull-request/usePrDetail';
|
||||||
import { usePrFiles } from '@/hooks/pull-request/usePrFiles';
|
import { usePrFiles } from '@/hooks/pull-request/usePrFiles';
|
||||||
import { usePrReviews } from '@/hooks/pull-request/usePrReviews';
|
import { usePrReviews } from '@/hooks/pull-request/usePrReviews';
|
||||||
|
import { useReopenPr } from '@/hooks/pull-request/useReopenPr';
|
||||||
import { cn, timeAgo } from '@/lib/utils';
|
import { cn, timeAgo } from '@/lib/utils';
|
||||||
|
|
||||||
export function RepoPrDetailPage() {
|
export function RepoPrDetailPage() {
|
||||||
const { workspaceName = '', repoName = '', prNumber } = useParams();
|
const { workspaceName = '', repoName = '', prNumber } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const number = Number(prNumber);
|
const number = Number(prNumber);
|
||||||
|
|
||||||
const { data: pr, isLoading, error } = usePrDetail(workspaceName, repoName, number);
|
const { data: pr, isLoading, error } = usePrDetail(workspaceName, repoName, number);
|
||||||
const { data: files } = usePrFiles(workspaceName, repoName, number);
|
const { data: files } = usePrFiles(workspaceName, repoName, number);
|
||||||
const { data: reviews } = usePrReviews(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 [commentBody, setCommentBody] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'files' | 'reviews'>('files');
|
const [activeTab, setActiveTab] = useState<'files' | 'reviews'>('files');
|
||||||
|
|
||||||
@@ -63,17 +73,59 @@ export function RepoPrDetailPage() {
|
|||||||
const isClosed = pr.state === 'Closed' || isMerged;
|
const isClosed = pr.state === 'Closed' || isMerged;
|
||||||
const isOpen = !isClosed;
|
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 (
|
return (
|
||||||
<div className="px-6 pt-6 pb-10">
|
<div className="space-y-6">
|
||||||
<Link
|
<Link
|
||||||
to={`/${workspaceName}/repos/${repoName}/pulls`}
|
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" />
|
<ArrowLeft className="size-3.5" />
|
||||||
Back to pull requests
|
Back to pull requests
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-1 shrink-0">
|
<div className="mt-1 shrink-0">
|
||||||
{isMerged ? (
|
{isMerged ? (
|
||||||
@@ -109,12 +161,18 @@ export function RepoPrDetailPage() {
|
|||||||
Draft
|
Draft
|
||||||
</span>
|
</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>
|
</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" />
|
<GitBranch className="size-4 text-muted-foreground" />
|
||||||
<span className="text-[13px] font-medium text-foreground">{pr.source_branch}</span>
|
<span className="text-[13px] font-medium text-foreground">{pr.source_branch}</span>
|
||||||
<span className="text-muted-foreground">→</span>
|
<span className="text-muted-foreground">→</span>
|
||||||
@@ -122,14 +180,14 @@ export function RepoPrDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pr.body && (
|
{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">
|
<div className="prose prose-sm max-w-none text-[14px] text-foreground whitespace-pre-wrap">
|
||||||
{pr.body}
|
{pr.body}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('files')}
|
onClick={() => setActiveTab('files')}
|
||||||
@@ -169,7 +227,7 @@ export function RepoPrDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'files' && (
|
{activeTab === 'files' && (
|
||||||
<div className="mb-8 space-y-2">
|
<div className="space-y-2">
|
||||||
{files && files.length > 0 ? (
|
{files && files.length > 0 ? (
|
||||||
files.map((file) => (
|
files.map((file) => (
|
||||||
<div
|
<div
|
||||||
@@ -207,7 +265,7 @@ export function RepoPrDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'reviews' && (
|
{activeTab === 'reviews' && (
|
||||||
<div className="mb-8 space-y-4">
|
<div className="space-y-4">
|
||||||
{reviews && reviews.length > 0 ? (
|
{reviews && reviews.length > 0 ? (
|
||||||
reviews.map((review) => (
|
reviews.map((review) => (
|
||||||
<div key={review.id} className="rounded-xl border border-border bg-card p-4">
|
<div key={review.id} className="rounded-xl border border-border bg-card p-4">
|
||||||
@@ -253,6 +311,8 @@ export function RepoPrDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<CircleCheck className="size-3.5 text-purple-500" />
|
||||||
Approve
|
Approve
|
||||||
@@ -260,7 +320,8 @@ export function RepoPrDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Send className="size-3.5" />
|
||||||
@@ -270,24 +331,41 @@ export function RepoPrDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<GitMerge className="size-3.5" />
|
||||||
Merge
|
{mergePr.isPending ? 'Merging...' : 'Merge'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<CircleDot className="size-3.5 text-red-500" />
|
||||||
Close PR
|
Close PR
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{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}
|
||||||
|
>
|
||||||
|
<Circle className="size-3.5 text-green-500" />
|
||||||
|
Reopen PR
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import type { PullRequestDetail } from '@/client/models/PullRequestDetail';
|
import type { PullRequestDetail } from '@/client/models/PullRequestDetail';
|
||||||
import { Pagination } from '@/components/repo/Pagination';
|
import { Pagination } from '@/components/repo/Pagination';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useRepoPulls } from '@/hooks/repo/useRepoPulls';
|
import { useRepoPulls } from '@/hooks/repo/useRepoPulls';
|
||||||
@@ -48,6 +49,17 @@ export function RepoPullsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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="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">
|
<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">
|
<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}
|
value={defaultBranch}
|
||||||
onChange={(e) => setDefaultBranch(e.target.value)}
|
onChange={(e) => setDefaultBranch(e.target.value)}
|
||||||
>
|
>
|
||||||
{branches.map((b) => (
|
{branches.map((b: { name: string }) => (
|
||||||
<option key={b.id} value={b.name}>
|
<option key={b.name} value={b.name}>
|
||||||
{b.name}
|
{b.name}
|
||||||
</option>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useRepoTags } from '@/hooks/repo/useRepoTags';
|
import { useRepoTags } from '@/hooks/repo/useRepoTags';
|
||||||
import { timeAgo } from '@/lib/utils';
|
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
|
||||||
@@ -30,8 +29,8 @@ export function RepoTagsPage() {
|
|||||||
workspaceName,
|
workspaceName,
|
||||||
repoName,
|
repoName,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
name: newName,
|
tag_name: newName,
|
||||||
target_commit_sha: newTarget || 'HEAD',
|
target: newTarget || 'HEAD',
|
||||||
message: newMessage || undefined,
|
message: newMessage || undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -45,13 +44,15 @@ export function RepoTagsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteTag = useMutation({
|
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'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['repo', workspaceName, repoName, 'tags'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const list = tags ?? [];
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? (tags ?? []).filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
|
? list.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
: (tags ?? []);
|
: list;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -66,7 +67,7 @@ export function RepoTagsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] text-muted-foreground">
|
<span className="text-[12px] text-muted-foreground">
|
||||||
{(tags ?? []).length} tag{(tags ?? []).length !== 1 ? 's' : ''}
|
{list.length} tag{list.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" className="ml-auto" onClick={() => setShowCreate(!showCreate)}>
|
<Button size="sm" className="ml-auto" onClick={() => setShowCreate(!showCreate)}>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -84,7 +85,7 @@ export function RepoTagsPage() {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-[13px]"
|
className="h-8 text-[13px]"
|
||||||
placeholder="Target commit SHA (optional, defaults to HEAD)"
|
placeholder="Target (branch/commit, defaults to HEAD)"
|
||||||
value={newTarget}
|
value={newTarget}
|
||||||
onChange={(e) => setNewTarget(e.target.value)}
|
onChange={(e) => setNewTarget(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -119,14 +120,14 @@ export function RepoTagsPage() {
|
|||||||
<div>
|
<div>
|
||||||
{filtered.map((tag) => (
|
{filtered.map((tag) => (
|
||||||
<div
|
<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"
|
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" />
|
<Tag className="size-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-[13px] font-semibold text-foreground">{tag.name}</span>
|
<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">
|
<span className="rounded border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
signed
|
annotated
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{tag.message && (
|
{tag.message && (
|
||||||
@@ -135,18 +136,17 @@ export function RepoTagsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{tag.target_oid?.hex && (
|
||||||
<code className="text-[10px] text-muted-foreground/60">
|
<code className="text-[10px] text-muted-foreground/60">
|
||||||
{tag.target_commit_sha.slice(0, 7)}
|
{tag.target_oid.hex.slice(0, 7)}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
)}
|
||||||
{timeAgo(tag.created_at)}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => {
|
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" />
|
<Trash2 className="size-3.5" />
|
||||||
@@ -158,7 +158,7 @@ export function RepoTagsPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
offset={offset}
|
offset={offset}
|
||||||
limit={LIMIT}
|
limit={LIMIT}
|
||||||
hasMore={(tags ?? []).length >= LIMIT}
|
hasMore={list.length >= LIMIT}
|
||||||
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
|
onPrev={() => setOffset(Math.max(0, offset - LIMIT))}
|
||||||
onNext={() => setOffset(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 { 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 { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useDeleteWikiPage } from '@/hooks/wiki/useDeleteWikiPage';
|
||||||
|
import { useUpdateWikiPage } from '@/hooks/wiki/useUpdateWikiPage';
|
||||||
import { useWikiPage } from '@/hooks/wiki/useWikiPage';
|
import { useWikiPage } from '@/hooks/wiki/useWikiPage';
|
||||||
import { useWikiRevisions } from '@/hooks/wiki/useWikiRevisions';
|
import { useWikiRevisions } from '@/hooks/wiki/useWikiRevisions';
|
||||||
import { cn, timeAgo } from '@/lib/utils';
|
import { cn, timeAgo } from '@/lib/utils';
|
||||||
|
|
||||||
export function RepoWikiDetailPage() {
|
export function RepoWikiDetailPage() {
|
||||||
const { workspaceName = '', repoName = '', slug } = useParams();
|
const { workspaceName = '', repoName = '', slug } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: page, isLoading, error } = useWikiPage(workspaceName, repoName, slug ?? null);
|
const { data: page, isLoading, error } = useWikiPage(workspaceName, repoName, slug ?? null);
|
||||||
const { data: revisions } = useWikiRevisions(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 [activeTab, setActiveTab] = useState<'content' | 'history'>('content');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -44,17 +52,111 @@ export function RepoWikiDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
setEditTitle(page.title);
|
||||||
|
setEditContent(page.content);
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="px-6 pt-6 pb-10">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to={`/${workspaceName}/repos/${repoName}/wiki`}
|
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"
|
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-3.5" />
|
<ArrowLeft className="size-3.5" />
|
||||||
Back to wiki
|
Back to wiki
|
||||||
</Link>
|
</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-6">
|
{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>
|
<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">
|
<div className="mt-2 flex items-center gap-3 text-[12px] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -65,7 +167,7 @@ export function RepoWikiDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('content')}
|
onClick={() => setActiveTab('content')}
|
||||||
@@ -140,6 +242,8 @@ export function RepoWikiDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,36 @@ import { BookOpen, Clock, FileText, Plus, Search } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useCreateWikiPage } from '@/hooks/wiki/useCreateWikiPage';
|
||||||
import { useWikiPages } from '@/hooks/wiki/useWikiPages';
|
import { useWikiPages } from '@/hooks/wiki/useWikiPages';
|
||||||
import { timeAgo } from '@/lib/utils';
|
import { timeAgo } from '@/lib/utils';
|
||||||
|
|
||||||
export function RepoWikiPage() {
|
export function RepoWikiPage() {
|
||||||
const { workspaceName = '', repoName = '' } = useParams();
|
const { workspaceName = '', repoName = '' } = useParams();
|
||||||
const [search, setSearch] = useState('');
|
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 { data: pages, isLoading } = useWikiPages(workspaceName, repoName, search || null);
|
||||||
|
const createPage = useCreateWikiPage(workspaceName, repoName);
|
||||||
|
|
||||||
const list = pages ?? [];
|
const list = pages ?? [];
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!newTitle.trim()) return;
|
||||||
|
createPage.mutate(
|
||||||
|
{ title: newTitle.trim(), content: newContent.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewTitle('');
|
||||||
|
setNewContent('');
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
@@ -28,12 +48,49 @@ export function RepoWikiPage() {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Plus className="size-3.5" />
|
||||||
New page
|
New page
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2 py-12 text-[14px] text-muted-foreground">
|
<div className="flex items-center gap-2 py-12 text-[14px] text-muted-foreground">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@@ -45,9 +102,19 @@ export function RepoWikiPage() {
|
|||||||
{search ? 'No pages match your search' : 'No wiki pages yet'}
|
{search ? 'No pages match your search' : 'No wiki pages yet'}
|
||||||
</p>
|
</p>
|
||||||
{!search && (
|
{!search && (
|
||||||
|
<>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Create the first page to get started
|
Create the first page to get started
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+23
-1
@@ -10,13 +10,24 @@ import { RootLayout } from '@/pages/RootLayout';
|
|||||||
import { IssueDetailPage } from '@/pages/Workspace/Issues/IssueDetailPage';
|
import { IssueDetailPage } from '@/pages/Workspace/Issues/IssueDetailPage';
|
||||||
import { WorkspaceIssuesPage } from '@/pages/Workspace/Issues/WorkspaceIssuesPage';
|
import { WorkspaceIssuesPage } from '@/pages/Workspace/Issues/WorkspaceIssuesPage';
|
||||||
import { WorkspaceMembersPage } from '@/pages/Workspace/Members/WorkspaceMembersPage';
|
import { WorkspaceMembersPage } from '@/pages/Workspace/Members/WorkspaceMembersPage';
|
||||||
|
import { RepoBlobPage } from '@/pages/Workspace/Repo/RepoBlobPage';
|
||||||
import { RepoBranchesPage } from '@/pages/Workspace/Repo/RepoBranchesPage';
|
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 { RepoLayout } from '@/pages/Workspace/Repo/RepoLayout';
|
||||||
|
import { RepoMembersPage } from '@/pages/Workspace/Repo/RepoMembersPage';
|
||||||
import { RepoOverviewPage } from '@/pages/Workspace/Repo/RepoOverviewPage';
|
import { RepoOverviewPage } from '@/pages/Workspace/Repo/RepoOverviewPage';
|
||||||
import { RepoPrDetailPage } from '@/pages/Workspace/Repo/RepoPrDetailPage';
|
import { RepoPrDetailPage } from '@/pages/Workspace/Repo/RepoPrDetailPage';
|
||||||
import { RepoPullsPage } from '@/pages/Workspace/Repo/RepoPullsPage';
|
import { RepoPullsPage } from '@/pages/Workspace/Repo/RepoPullsPage';
|
||||||
|
import { RepoReleasesPage } from '@/pages/Workspace/Repo/RepoReleasesPage';
|
||||||
import { RepoSettingsPage } from '@/pages/Workspace/Repo/RepoSettingsPage';
|
import { RepoSettingsPage } from '@/pages/Workspace/Repo/RepoSettingsPage';
|
||||||
|
import { RepoStarsPage } from '@/pages/Workspace/Repo/RepoStarsPage';
|
||||||
import { RepoTagsPage } from '@/pages/Workspace/Repo/RepoTagsPage';
|
import { RepoTagsPage } from '@/pages/Workspace/Repo/RepoTagsPage';
|
||||||
|
import { RepoWatchersPage } from '@/pages/Workspace/Repo/RepoWatchersPage';
|
||||||
import { RepoWebhooksPage } from '@/pages/Workspace/Repo/RepoWebhooksPage';
|
import { RepoWebhooksPage } from '@/pages/Workspace/Repo/RepoWebhooksPage';
|
||||||
import { RepoWikiDetailPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiDetailPage';
|
import { RepoWikiDetailPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiDetailPage';
|
||||||
import { RepoWikiPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiPage';
|
import { RepoWikiPage } from '@/pages/Workspace/Repo/Wiki/RepoWikiPage';
|
||||||
@@ -41,15 +52,26 @@ export const router = createBrowserRouter([
|
|||||||
path: 'repos/:repoName',
|
path: 'repos/:repoName',
|
||||||
Component: RepoLayout,
|
Component: RepoLayout,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, Component: RepoOverviewPage },
|
{ index: true, Component: RepoCodePage },
|
||||||
|
{ path: 'overview', Component: RepoOverviewPage },
|
||||||
{ path: 'branches', Component: RepoBranchesPage },
|
{ path: 'branches', Component: RepoBranchesPage },
|
||||||
{ path: 'tags', Component: RepoTagsPage },
|
{ path: 'tags', Component: RepoTagsPage },
|
||||||
|
{ path: 'releases', Component: RepoReleasesPage },
|
||||||
|
{ path: 'commits', Component: RepoCommitsPage },
|
||||||
|
{ path: 'commits/:commitSha', Component: RepoCommitDetailPage },
|
||||||
{ path: 'pulls', Component: RepoPullsPage },
|
{ path: 'pulls', Component: RepoPullsPage },
|
||||||
|
{ path: 'pulls/new', Component: RepoCreatePrPage },
|
||||||
{ path: 'pulls/:prNumber', Component: RepoPrDetailPage },
|
{ 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: 'webhooks', Component: RepoWebhooksPage },
|
||||||
{ path: 'wiki', Component: RepoWikiPage },
|
{ path: 'wiki', Component: RepoWikiPage },
|
||||||
{ path: 'wiki/:slug', Component: RepoWikiDetailPage },
|
{ path: 'wiki/:slug', Component: RepoWikiDetailPage },
|
||||||
{ path: 'settings', Component: RepoSettingsPage },
|
{ path: 'settings', Component: RepoSettingsPage },
|
||||||
|
{ path: 'blob', Component: RepoBlobPage },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: 'issues', Component: WorkspaceIssuesPage },
|
{ path: 'issues', Component: WorkspaceIssuesPage },
|
||||||
|
|||||||
Reference in New Issue
Block a user