@tequila99/githuman 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/db/migrations.js +33 -4
- package/dist/server/repositories/comment.repo.js +3 -2
- package/dist/server/repositories/review-file.repo.js +17 -0
- package/dist/server/repositories/review.repo.js +53 -6
- package/dist/server/routes/comments.js +1 -0
- package/dist/server/routes/reviews.js +24 -3
- package/dist/server/services/comment.service.js +1 -0
- package/dist/server/services/diff.service.js +30 -12
- package/dist/server/services/git.service.js +102 -10
- package/dist/server/services/review.service.js +117 -18
- package/dist/web/assets/AppPage-VD8mwL62.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-B4Ut5O1Z.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-BqWdgfEf.css +1 -0
- package/dist/web/assets/ErrorNotFound-DMYc7a3H.js +1 -0
- package/dist/web/assets/IndexPage-BQ0T9rLY.css +1 -0
- package/dist/web/assets/IndexPage-_YmeSrRQ.js +1 -0
- package/dist/web/assets/MainLayout-D2-McXhW.js +1 -0
- package/dist/web/assets/Platform-CEDL_PgC.js +1 -0
- package/dist/web/assets/QBtn-Cf6WtiKt.js +1 -0
- package/dist/web/assets/QItemSection-DA4jtl1i.js +1 -0
- package/dist/web/assets/QList-BeOKfebe.js +1 -0
- package/dist/web/assets/QTooltip-DAb5vBzv.js +1 -0
- package/dist/web/assets/ReviewDetailPage-BlTGnC8d.js +1 -0
- package/dist/web/assets/ReviewDetailPage-DJHzinT1.css +1 -0
- package/dist/web/assets/ReviewsPage-Cu0NzNHQ.js +1 -0
- package/dist/web/assets/ReviewsPage-D9TFy2OP.css +1 -0
- package/dist/web/assets/StatusSwitcher-BQBbWZPW.js +3 -0
- package/dist/web/assets/StatusSwitcher-CZVJtHnr.css +1 -0
- package/dist/web/assets/client-De34x_P_.js +1 -0
- package/dist/web/assets/event-BQt_DXaR.js +1 -0
- package/dist/web/assets/format-B_1hRYcb.js +1 -0
- package/dist/web/assets/i18n-D0uDA2bL.js +1 -0
- package/dist/web/assets/index-Dl3zmXcR.js +2 -0
- package/dist/web/assets/{index-Fo431P2N.css → index-JAYe_SFI.css} +1 -1
- package/dist/web/assets/init-app-DDJ8U-8D.js +1 -0
- package/dist/web/assets/pinia-C3vnQv9G.js +1 -0
- package/dist/web/assets/pinia-DK4OjQ7o.js +1 -0
- package/dist/web/assets/repository-store-CBxek7MZ.js +1 -0
- package/dist/web/assets/runtime-core.esm-bundler-Wxr8ezGp.js +1 -0
- package/dist/web/assets/theme-CQChVMvK.js +1 -0
- package/dist/web/assets/{use-app-theme-DrR0AvTg.js → use-app-theme-DvzGyhQd.js} +1 -1
- package/dist/web/assets/{vue-i18n.runtime-nPdXRlNg.js → vue-i18n.runtime-DSeWNwrW.js} +2 -2
- package/dist/web/index.html +6 -4
- package/package.json +1 -1
- package/dist/web/assets/ErrorNotFound-C0A8n_dh.js +0 -1
- package/dist/web/assets/IndexPage-Ca7Xb-lc.js +0 -3
- package/dist/web/assets/IndexPage-Dn15ye8s.css +0 -1
- package/dist/web/assets/MainLayout-DHuQjMOV.js +0 -1
- package/dist/web/assets/QPage-DLIDrL1Z.js +0 -1
- package/dist/web/assets/ReviewsPage-f8fJEuU9.js +0 -1
- package/dist/web/assets/client-TNhDHmkt.js +0 -1
- package/dist/web/assets/create-C46qMo0y.js +0 -1
- package/dist/web/assets/format-SW_H2PBI.js +0 -1
- package/dist/web/assets/i18n-RyrLuvXU.js +0 -1
- package/dist/web/assets/index-CRwZKCt6.js +0 -2
- package/dist/web/assets/init-app-DSQzny_G.js +0 -1
- package/dist/web/assets/pinia-BDCzxZ18.js +0 -1
- package/dist/web/assets/pinia-BJouyyXU.js +0 -1
- package/dist/web/assets/repository-store-DqnwJIJW.js +0 -1
- package/dist/web/assets/runtime-core.esm-bundler-oS9U9Vp7.js +0 -1
- package/dist/web/assets/theme-DUV6mF_4.js +0 -1
|
@@ -22,13 +22,42 @@ export function applyMigrations(db) {
|
|
|
22
22
|
created_at TEXT NOT NULL,
|
|
23
23
|
updated_at TEXT NOT NULL
|
|
24
24
|
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS review_files (
|
|
27
|
+
review_id TEXT NOT NULL REFERENCES reviews(id),
|
|
28
|
+
file_path TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS review_files_file_path_idx
|
|
32
|
+
ON review_files(file_path);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS review_files_review_id_idx
|
|
34
|
+
ON review_files(review_id);
|
|
25
35
|
`);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
const commentColumns = db
|
|
37
|
+
.prepare('PRAGMA table_info(comments)')
|
|
38
|
+
.all();
|
|
39
|
+
const commentColumnNames = new Set(commentColumns.map(c => c.name));
|
|
40
|
+
if (!commentColumnNames.has('resolved')) {
|
|
29
41
|
db.exec('ALTER TABLE comments ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0');
|
|
30
42
|
}
|
|
31
|
-
if (!
|
|
43
|
+
if (!commentColumnNames.has('suggestion')) {
|
|
32
44
|
db.exec('ALTER TABLE comments ADD COLUMN suggestion TEXT');
|
|
33
45
|
}
|
|
46
|
+
if (!commentColumnNames.has('line_number_end')) {
|
|
47
|
+
db.exec('ALTER TABLE comments ADD COLUMN line_number_end INTEGER');
|
|
48
|
+
}
|
|
49
|
+
const reviewColumns = db
|
|
50
|
+
.prepare('PRAGMA table_info(reviews)')
|
|
51
|
+
.all();
|
|
52
|
+
const reviewColumnNames = new Set(reviewColumns.map(c => c.name));
|
|
53
|
+
if (!reviewColumnNames.has('name')) {
|
|
54
|
+
db.exec('ALTER TABLE reviews ADD COLUMN name TEXT');
|
|
55
|
+
}
|
|
56
|
+
if (!reviewColumnNames.has('branch')) {
|
|
57
|
+
db.exec('ALTER TABLE reviews ADD COLUMN branch TEXT');
|
|
58
|
+
}
|
|
59
|
+
// NULL is never considered equal to NULL by SQLite's UNIQUE constraint,
|
|
60
|
+
// so pre-existing reviews with name/branch = NULL don't collide with
|
|
61
|
+
// each other or block this index from being created (see ADR 0017).
|
|
62
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS reviews_name_branch_uq ON reviews(name, branch)');
|
|
34
63
|
}
|
|
@@ -4,6 +4,7 @@ function rowToComment(row) {
|
|
|
4
4
|
reviewId: row.review_id,
|
|
5
5
|
filePath: row.file_path,
|
|
6
6
|
lineNumber: row.line_number,
|
|
7
|
+
lineNumberEnd: row.line_number_end,
|
|
7
8
|
lineType: row.line_type,
|
|
8
9
|
content: row.content,
|
|
9
10
|
createdAt: row.created_at,
|
|
@@ -17,8 +18,8 @@ export function findCommentById(db, id) {
|
|
|
17
18
|
return row ? rowToComment(row) : null;
|
|
18
19
|
}
|
|
19
20
|
export function createComment(db, comment) {
|
|
20
|
-
db.prepare(`INSERT INTO comments (id, review_id, file_path, line_number, line_type, content, created_at, updated_at, suggestion)
|
|
21
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(comment.id, comment.reviewId, comment.filePath, comment.lineNumber, comment.lineType, comment.content, comment.createdAt, comment.updatedAt, comment.suggestion ?? null);
|
|
21
|
+
db.prepare(`INSERT INTO comments (id, review_id, file_path, line_number, line_number_end, line_type, content, created_at, updated_at, suggestion)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(comment.id, comment.reviewId, comment.filePath, comment.lineNumber, comment.lineNumberEnd, comment.lineType, comment.content, comment.createdAt, comment.updatedAt, comment.suggestion ?? null);
|
|
22
23
|
return findCommentById(db, comment.id);
|
|
23
24
|
}
|
|
24
25
|
export function listCommentsByReview(db, reviewId) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Denormalized index of file paths touched by a review's snapshot, used only
|
|
3
|
+
* to filter the review list by file (see ADR 0017). Not a source of truth —
|
|
4
|
+
* `reviews.snapshot_data` remains the only source of truth for diff rendering.
|
|
5
|
+
*/
|
|
6
|
+
export function insertReviewFiles(db, reviewId, filePaths) {
|
|
7
|
+
if (filePaths.length === 0) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const stmt = db.prepare('INSERT INTO review_files (review_id, file_path) VALUES (?, ?)');
|
|
11
|
+
for (const filePath of filePaths) {
|
|
12
|
+
stmt.run(reviewId, filePath);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function deleteReviewFiles(db, reviewId) {
|
|
16
|
+
db.prepare('DELETE FROM review_files WHERE review_id = ?').run(reviewId);
|
|
17
|
+
}
|
|
@@ -7,24 +7,71 @@ function rowToReview(row) {
|
|
|
7
7
|
sourceRef: row.source_ref,
|
|
8
8
|
snapshotData: row.snapshot_data,
|
|
9
9
|
status: row.status,
|
|
10
|
+
name: row.name,
|
|
11
|
+
branch: row.branch,
|
|
10
12
|
createdAt: row.created_at,
|
|
11
13
|
updatedAt: row.updated_at
|
|
12
14
|
};
|
|
13
15
|
}
|
|
16
|
+
/** Thrown by `createReview` when `(name, branch)` collides with an existing review — see ADR 0017. */
|
|
17
|
+
export class UniqueNameError extends Error {
|
|
18
|
+
}
|
|
14
19
|
export function createReview(db, review) {
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
try {
|
|
21
|
+
db.prepare(`INSERT INTO reviews (id, repository_path, base_ref, source_type, source_ref, snapshot_data, status, name, branch, created_at, updated_at)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(review.id, review.repositoryPath, review.baseRef, review.sourceType, review.sourceRef, review.snapshotData, review.status, review.name, review.branch, review.createdAt, review.updatedAt);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof Error &&
|
|
26
|
+
/UNIQUE constraint failed: reviews\.name, reviews\.branch/.test(error.message)) {
|
|
27
|
+
throw new UniqueNameError(`A review named "${review.name}" already exists on branch "${review.branch}"`);
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
17
31
|
return review;
|
|
18
32
|
}
|
|
19
33
|
export function findReviewById(db, id) {
|
|
20
34
|
const row = db.prepare('SELECT * FROM reviews WHERE id = ?').get(id);
|
|
21
35
|
return row ? rowToReview(row) : null;
|
|
22
36
|
}
|
|
23
|
-
export function listReviews(db) {
|
|
37
|
+
export function listReviews(db, filters = {}) {
|
|
38
|
+
const { branch, search, createdFrom, createdTo, filePaths } = filters;
|
|
39
|
+
const conditions = [];
|
|
40
|
+
const params = [];
|
|
41
|
+
const joins = [];
|
|
42
|
+
if (branch !== undefined) {
|
|
43
|
+
conditions.push('reviews.branch = ?');
|
|
44
|
+
params.push(branch);
|
|
45
|
+
}
|
|
46
|
+
if (createdFrom) {
|
|
47
|
+
conditions.push('reviews.created_at >= ?');
|
|
48
|
+
params.push(createdFrom);
|
|
49
|
+
}
|
|
50
|
+
if (createdTo) {
|
|
51
|
+
conditions.push('reviews.created_at <= ?');
|
|
52
|
+
params.push(createdTo);
|
|
53
|
+
}
|
|
54
|
+
if (filePaths && filePaths.length > 0) {
|
|
55
|
+
joins.push('JOIN review_files ON review_files.review_id = reviews.id');
|
|
56
|
+
conditions.push(`review_files.file_path IN (${filePaths.map(() => '?').join(', ')})`);
|
|
57
|
+
params.push(...filePaths);
|
|
58
|
+
}
|
|
59
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
24
60
|
const rows = db
|
|
25
|
-
.prepare(
|
|
26
|
-
|
|
27
|
-
|
|
61
|
+
.prepare(`SELECT DISTINCT reviews.* FROM reviews
|
|
62
|
+
${joins.join(' ')}
|
|
63
|
+
${whereClause}
|
|
64
|
+
ORDER BY reviews.created_at DESC`)
|
|
65
|
+
.all(...params);
|
|
66
|
+
const reviews = rows.map(rowToReview);
|
|
67
|
+
// Done in JS, not SQL `LOWER()`/`NOCASE` (ASCII-only in SQLite without the
|
|
68
|
+
// ICU extension) — `String.prototype.toLowerCase()` handles Cyrillic and
|
|
69
|
+
// other non-ASCII scripts correctly (see ADR 0017).
|
|
70
|
+
if (!search) {
|
|
71
|
+
return reviews;
|
|
72
|
+
}
|
|
73
|
+
const needle = search.toLowerCase();
|
|
74
|
+
return reviews.filter(review => review.name?.toLowerCase().includes(needle));
|
|
28
75
|
}
|
|
29
76
|
export function deleteReview(db, id) {
|
|
30
77
|
db.prepare('DELETE FROM reviews WHERE id = ?').run(id);
|
|
@@ -4,6 +4,7 @@ import { createComment, getComments, editComment, removeComment, resolveComment,
|
|
|
4
4
|
const CreateCommentBody = Type.Object({
|
|
5
5
|
filePath: Type.String({ minLength: 1 }),
|
|
6
6
|
lineNumber: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
7
|
+
lineNumberEnd: Type.Optional(Type.Union([Type.Integer(), Type.Null()])),
|
|
7
8
|
lineType: Type.Optional(Type.Union([
|
|
8
9
|
Type.Literal('added'),
|
|
9
10
|
Type.Literal('removed'),
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { Type } from '@sinclair/typebox';
|
|
2
2
|
import { createReview, getReview, getReviews, setReviewStatus, removeReview, ValidationError } from "../services/review.service.js";
|
|
3
|
+
import { getRepositoryInfo } from "../services/git.service.js";
|
|
3
4
|
const CreateReviewBody = Type.Object({
|
|
4
5
|
sourceType: Type.Optional(Type.Union([
|
|
6
|
+
Type.Literal('local'),
|
|
5
7
|
Type.Literal('staged'),
|
|
6
8
|
Type.Literal('unstaged'),
|
|
7
9
|
Type.Literal('branch'),
|
|
8
10
|
Type.Literal('commits')
|
|
9
11
|
])),
|
|
10
12
|
sourceRef: Type.Optional(Type.String()),
|
|
11
|
-
baseRef: Type.Optional(Type.String())
|
|
13
|
+
baseRef: Type.Optional(Type.String()),
|
|
14
|
+
name: Type.Optional(Type.String())
|
|
15
|
+
});
|
|
16
|
+
const ReviewsQuery = Type.Object({
|
|
17
|
+
branch: Type.Optional(Type.String()),
|
|
18
|
+
search: Type.Optional(Type.String()),
|
|
19
|
+
createdFrom: Type.Optional(Type.String()),
|
|
20
|
+
createdTo: Type.Optional(Type.String()),
|
|
21
|
+
/** Comma-separated file paths — reviews matching any of them are returned. */
|
|
22
|
+
files: Type.Optional(Type.String())
|
|
12
23
|
});
|
|
13
24
|
const UpdateReviewBody = Type.Object({
|
|
14
25
|
status: Type.Optional(Type.Union([
|
|
@@ -39,8 +50,18 @@ export async function reviewRoutes(app, opts) {
|
|
|
39
50
|
throw error;
|
|
40
51
|
}
|
|
41
52
|
});
|
|
42
|
-
typedApp.get('/api/reviews', async () => {
|
|
43
|
-
|
|
53
|
+
typedApp.get('/api/reviews', { schema: { querystring: ReviewsQuery } }, async (request) => {
|
|
54
|
+
const { branch, search, createdFrom, createdTo, files } = request.query;
|
|
55
|
+
const resolvedBranch = branch ?? (await getRepositoryInfo(repositoryPath)).branch;
|
|
56
|
+
return getReviews(db, {
|
|
57
|
+
branch: resolvedBranch,
|
|
58
|
+
search,
|
|
59
|
+
createdFrom,
|
|
60
|
+
createdTo,
|
|
61
|
+
filePaths: files
|
|
62
|
+
? files.split(',').filter(path => path.length > 0)
|
|
63
|
+
: undefined
|
|
64
|
+
});
|
|
44
65
|
});
|
|
45
66
|
typedApp.get('/api/reviews/:id', { schema: { params: ReviewIdParams } }, async (request, reply) => {
|
|
46
67
|
const review = getReview(db, request.params.id);
|
|
@@ -11,6 +11,7 @@ export function createComment(db, reviewId, input, eventBus) {
|
|
|
11
11
|
reviewId,
|
|
12
12
|
filePath: input.filePath,
|
|
13
13
|
lineNumber: input.lineNumber ?? null,
|
|
14
|
+
lineNumberEnd: input.lineNumberEnd ?? input.lineNumber ?? null,
|
|
14
15
|
lineType: input.lineType ?? null,
|
|
15
16
|
content: input.content,
|
|
16
17
|
createdAt: now,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { diffLines } from 'diff';
|
|
2
|
-
import { assertSafeRef, getFileAtRef, listChangedPaths, listUntrackedPaths } from "./git.service.js";
|
|
2
|
+
import { assertSafeRef, getFileAtRef, getFilesAtRefBatch, listChangedPaths, listUntrackedPaths } from "./git.service.js";
|
|
3
3
|
function splitLines(text) {
|
|
4
4
|
if (text === '')
|
|
5
5
|
return [];
|
|
@@ -99,19 +99,37 @@ export function computeFileDiff(oldContent, newContent, meta) {
|
|
|
99
99
|
const hunks = windowHunks(lines);
|
|
100
100
|
return { ...base, additions, deletions, hunks };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Reads one diff side's content for every changed path, batching all
|
|
104
|
+
* git-ref reads into a single `git cat-file --batch` process instead of one
|
|
105
|
+
* `git show` per file (see ADR 0019) — `WORKTREE` still reads straight off
|
|
106
|
+
* disk (already process-free) since it isn't a git object. Falls back to
|
|
107
|
+
* per-file `getFileAtRef` if the batch process fails to spawn or crashes
|
|
108
|
+
* mid-read, so a batching problem degrades performance rather than
|
|
109
|
+
* correctness.
|
|
110
|
+
*/
|
|
111
|
+
async function readDiffSide(repoPath, ref, paths) {
|
|
112
|
+
if (ref === 'WORKTREE' || paths.length === 0) {
|
|
113
|
+
return Promise.all(paths.map(path => getFileAtRef(repoPath, ref, path)));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return await getFilesAtRefBatch(repoPath, paths.map(path => ({ ref, path })));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return Promise.all(paths.map(path => getFileAtRef(repoPath, ref, path)));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
102
122
|
async function buildDiffFiles(repoPath, diffArgs, oldRef, newRef) {
|
|
103
123
|
const changes = await listChangedPaths(repoPath, diffArgs);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
isBinary: oldFile.isBinary || newFile.isBinary
|
|
114
|
-
});
|
|
124
|
+
const [oldFiles, newFiles] = await Promise.all([
|
|
125
|
+
readDiffSide(repoPath, oldRef, changes.map(change => change.oldPath)),
|
|
126
|
+
readDiffSide(repoPath, newRef, changes.map(change => change.newPath))
|
|
127
|
+
]);
|
|
128
|
+
return changes.map((change, i) => computeFileDiff(oldFiles[i].content, newFiles[i].content, {
|
|
129
|
+
oldPath: change.oldPath,
|
|
130
|
+
newPath: change.newPath,
|
|
131
|
+
status: change.status,
|
|
132
|
+
isBinary: oldFiles[i].isBinary || newFiles[i].isBinary
|
|
115
133
|
}));
|
|
116
134
|
}
|
|
117
135
|
export async function getStagedDiff(repoPath) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, resolve, sep } from 'node:path';
|
|
@@ -30,6 +30,23 @@ export function assertSafeRef(ref) {
|
|
|
30
30
|
throw new Error(`Invalid git ref "${ref}": refs may not start with "-"`);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds the `git show`/`cat-file` object spec for a file at a ref (e.g.
|
|
35
|
+
* `HEAD:src/foo.ts`, or `:src/foo.ts` for the INDEX/staged form). Shared by
|
|
36
|
+
* `getFileAtRef` and `getFilesAtRefBatch` so both single-file and batched
|
|
37
|
+
* reads resolve refs identically.
|
|
38
|
+
*/
|
|
39
|
+
function objectSpecFor(ref, path) {
|
|
40
|
+
if (ref !== 'INDEX') {
|
|
41
|
+
assertSafeRef(ref);
|
|
42
|
+
}
|
|
43
|
+
// No `--` before the spec: for the INDEX form (`:path`) it would make git
|
|
44
|
+
// treat the pathspec-looking `:path` as a plain path instead of the
|
|
45
|
+
// special "file staged in the index" syntax, silently changing meaning
|
|
46
|
+
// (falls back to showing HEAD). assertSafeRef above already rules out
|
|
47
|
+
// the injection this would otherwise guard against.
|
|
48
|
+
return ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
|
|
49
|
+
}
|
|
33
50
|
/**
|
|
34
51
|
* Reads a file's content at a given git ref.
|
|
35
52
|
*
|
|
@@ -54,16 +71,8 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
54
71
|
return { content: '', isBinary: false };
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
|
-
|
|
58
|
-
assertSafeRef(ref);
|
|
59
|
-
}
|
|
60
|
-
const gitRef = ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
|
|
74
|
+
const gitRef = objectSpecFor(ref, path);
|
|
61
75
|
try {
|
|
62
|
-
// No `--` before gitRef: for the INDEX form (`:path`) it would make git
|
|
63
|
-
// treat the pathspec-looking `:path` as a plain path instead of the
|
|
64
|
-
// special "file staged in the index" syntax, silently changing meaning
|
|
65
|
-
// (falls back to showing HEAD). assertSafeRef above already rules out
|
|
66
|
-
// the injection this would otherwise guard against.
|
|
67
76
|
const { stdout } = await execFileAsync('git', ['show', gitRef], {
|
|
68
77
|
cwd: repoPath,
|
|
69
78
|
encoding: 'buffer',
|
|
@@ -79,6 +88,89 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
79
88
|
return { content: '', isBinary: false };
|
|
80
89
|
}
|
|
81
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Parses `git cat-file --batch`'s output for `count` requested objects, in
|
|
93
|
+
* request order. Each entry is either `<sha> SP <type> SP <size> LF`
|
|
94
|
+
* followed by exactly `size` bytes of content and a trailing LF, or
|
|
95
|
+
* `<object> SP missing LF` (or another non-numeric-size error line, e.g.
|
|
96
|
+
* `ambiguous`/`notdir`) for a request that didn't resolve — treated the
|
|
97
|
+
* same as a missing file, matching `getFileAtRef`'s catch-all behavior.
|
|
98
|
+
*
|
|
99
|
+
* Parses by the announced byte size, not line-by-line — file content can
|
|
100
|
+
* itself contain arbitrary bytes, including `\n`.
|
|
101
|
+
*/
|
|
102
|
+
function parseCatFileBatchOutput(buffer, count) {
|
|
103
|
+
const results = [];
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (let i = 0; i < count; i++) {
|
|
106
|
+
const headerEnd = buffer.indexOf(0x0a, offset);
|
|
107
|
+
if (headerEnd === -1) {
|
|
108
|
+
throw new Error('git cat-file --batch: truncated output (missing header)');
|
|
109
|
+
}
|
|
110
|
+
const header = buffer.toString('utf-8', offset, headerEnd);
|
|
111
|
+
offset = headerEnd + 1;
|
|
112
|
+
const size = Number(header.slice(header.lastIndexOf(' ') + 1));
|
|
113
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
114
|
+
results.push({ content: '', isBinary: false });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const content = buffer.subarray(offset, offset + size);
|
|
118
|
+
if (content.length < size) {
|
|
119
|
+
throw new Error('git cat-file --batch: truncated output (short content)');
|
|
120
|
+
}
|
|
121
|
+
offset += size + 1; // skip content + its trailing LF
|
|
122
|
+
const isBinary = content.includes(0);
|
|
123
|
+
results.push({
|
|
124
|
+
content: isBinary ? '' : content.toString('utf-8'),
|
|
125
|
+
isBinary
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Reads many files' content at git refs in one `git cat-file --batch`
|
|
132
|
+
* process, instead of one `git show` process per file (see ADR 0019) —
|
|
133
|
+
* used by `buildDiffFiles` to avoid spawning `2 × changed files` processes
|
|
134
|
+
* for a single diff. Results come back in the same order as `requests`.
|
|
135
|
+
*
|
|
136
|
+
* Does not accept `ref: 'WORKTREE'` — worktree content isn't a git object;
|
|
137
|
+
* callers read it directly off disk instead (as `getFileAtRef` already
|
|
138
|
+
* does for that ref).
|
|
139
|
+
*/
|
|
140
|
+
export async function getFilesAtRefBatch(repoPath, requests) {
|
|
141
|
+
if (requests.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const specs = requests.map(({ ref, path }) => {
|
|
144
|
+
if (ref === 'WORKTREE') {
|
|
145
|
+
throw new Error("getFilesAtRefBatch does not support ref 'WORKTREE' — read worktree files directly instead");
|
|
146
|
+
}
|
|
147
|
+
return objectSpecFor(ref, path);
|
|
148
|
+
});
|
|
149
|
+
return new Promise((resolvePromise, reject) => {
|
|
150
|
+
const child = spawn('git', ['cat-file', '--batch'], { cwd: repoPath });
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let stderr = '';
|
|
153
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
154
|
+
child.stderr.on('data', (chunk) => {
|
|
155
|
+
stderr += chunk.toString('utf-8');
|
|
156
|
+
});
|
|
157
|
+
child.on('error', reject);
|
|
158
|
+
child.on('close', code => {
|
|
159
|
+
if (code !== 0) {
|
|
160
|
+
reject(new Error(`git cat-file --batch exited with code ${code}: ${stderr}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
resolvePromise(parseCatFileBatchOutput(Buffer.concat(chunks), requests.length));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
child.stdin.write(specs.join('\n') + '\n');
|
|
171
|
+
child.stdin.end();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
82
174
|
/**
|
|
83
175
|
* Lists every file path visible at a git ref, for browsing the whole
|
|
84
176
|
* codebase rather than just a diff. `'WORKTREE'` lists the working directory
|
|
@@ -1,14 +1,75 @@
|
|
|
1
|
-
import { createReview as insertReview, findReviewById, listReviews, updateReviewStatus as updateReviewStatusRepo, deleteReview as deleteReviewRepo } from "../repositories/review.repo.js";
|
|
1
|
+
import { createReview as insertReview, findReviewById, listReviews, updateReviewStatus as updateReviewStatusRepo, deleteReview as deleteReviewRepo, UniqueNameError } from "../repositories/review.repo.js";
|
|
2
2
|
import { listCommentsByReview, deleteComment } from "../repositories/comment.repo.js";
|
|
3
|
+
import { insertReviewFiles, deleteReviewFiles } from "../repositories/review-file.repo.js";
|
|
3
4
|
import { getStagedDiff, getUnstagedDiff, getBranchDiff, getCommitsDiff } from "./diff.service.js";
|
|
5
|
+
import { getRepositoryInfo } from "./git.service.js";
|
|
4
6
|
export class ValidationError extends Error {
|
|
5
7
|
}
|
|
8
|
+
const REVIEW_NAME_MONTHS_RU = [
|
|
9
|
+
'янв',
|
|
10
|
+
'февр',
|
|
11
|
+
'мар',
|
|
12
|
+
'апр',
|
|
13
|
+
'мая',
|
|
14
|
+
'июн',
|
|
15
|
+
'июл',
|
|
16
|
+
'авг',
|
|
17
|
+
'сент',
|
|
18
|
+
'окт',
|
|
19
|
+
'нояб',
|
|
20
|
+
'дек'
|
|
21
|
+
];
|
|
22
|
+
function formatReviewDate(date) {
|
|
23
|
+
const day = date.getDate();
|
|
24
|
+
const month = REVIEW_NAME_MONTHS_RU[date.getMonth()];
|
|
25
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
26
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
27
|
+
return `${day} ${month}, ${hours}:${minutes}`;
|
|
28
|
+
}
|
|
29
|
+
function reviewSourceLabel(sourceType, sourceRef, baseRef) {
|
|
30
|
+
if (sourceType === 'local') {
|
|
31
|
+
return 'Local changes';
|
|
32
|
+
}
|
|
33
|
+
if (sourceType === 'staged') {
|
|
34
|
+
return 'Staged';
|
|
35
|
+
}
|
|
36
|
+
if (sourceType === 'unstaged') {
|
|
37
|
+
return 'Unstaged';
|
|
38
|
+
}
|
|
39
|
+
if (sourceType === 'branch') {
|
|
40
|
+
return sourceRef ?? baseRef ?? 'Branch';
|
|
41
|
+
}
|
|
42
|
+
if (sourceRef) {
|
|
43
|
+
const commits = sourceRef.split(',');
|
|
44
|
+
return commits.length === 1
|
|
45
|
+
? `Commit ${commits[0].slice(0, 8)}`
|
|
46
|
+
: `${commits.length} commits`;
|
|
47
|
+
}
|
|
48
|
+
return 'Review';
|
|
49
|
+
}
|
|
50
|
+
/** Auto-generated name shown when the user doesn't provide one — "source + date/time" (see ADR 0017). */
|
|
51
|
+
function generateReviewName(sourceType, sourceRef, baseRef, now) {
|
|
52
|
+
return `${reviewSourceLabel(sourceType, sourceRef, baseRef)} — ${formatReviewDate(now)}`;
|
|
53
|
+
}
|
|
54
|
+
function extractFilePaths(snapshot) {
|
|
55
|
+
return snapshot.map(file => file.newPath || file.oldPath);
|
|
56
|
+
}
|
|
6
57
|
export async function createReview(db, repositoryPath, input, eventBus) {
|
|
7
|
-
const sourceType = input.sourceType ?? '
|
|
58
|
+
const sourceType = input.sourceType ?? 'local';
|
|
8
59
|
let snapshot;
|
|
9
60
|
let baseRef = null;
|
|
10
61
|
let sourceRef = null;
|
|
11
|
-
if (sourceType === '
|
|
62
|
+
if (sourceType === 'local') {
|
|
63
|
+
// Both diffs together — a review's comments apply regardless of which
|
|
64
|
+
// tab they were left on, so its snapshot (read-only /reviews/:id view,
|
|
65
|
+
// and the file-path filter in the reviews list) needs both too.
|
|
66
|
+
const [staged, unstaged] = await Promise.all([
|
|
67
|
+
getStagedDiff(repositoryPath),
|
|
68
|
+
getUnstagedDiff(repositoryPath)
|
|
69
|
+
]);
|
|
70
|
+
snapshot = [...staged, ...unstaged];
|
|
71
|
+
}
|
|
72
|
+
else if (sourceType === 'staged') {
|
|
12
73
|
snapshot = await getStagedDiff(repositoryPath);
|
|
13
74
|
}
|
|
14
75
|
else if (sourceType === 'unstaged') {
|
|
@@ -33,27 +94,64 @@ export async function createReview(db, repositoryPath, input, eventBus) {
|
|
|
33
94
|
else {
|
|
34
95
|
throw new ValidationError(`Unknown sourceType: "${String(sourceType)}"`);
|
|
35
96
|
}
|
|
36
|
-
const now = new Date()
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const nowIso = now.toISOString();
|
|
99
|
+
const { branch } = await getRepositoryInfo(repositoryPath);
|
|
100
|
+
const userProvidedName = input.name?.trim();
|
|
101
|
+
const baseName = userProvidedName || generateReviewName(sourceType, sourceRef, baseRef, now);
|
|
102
|
+
const filePaths = extractFilePaths(snapshot);
|
|
103
|
+
const id = crypto.randomUUID();
|
|
104
|
+
// A user-chosen name that collides is surfaced as a ValidationError — the
|
|
105
|
+
// user decides what to do. An auto-generated name (e.g. two "Staged"
|
|
106
|
+
// reviews created within the same minute) collides on nothing the user
|
|
107
|
+
// chose, so it's disambiguated automatically instead (see ADR 0017).
|
|
108
|
+
const maxAttempts = userProvidedName ? 1 : 20;
|
|
109
|
+
let created;
|
|
110
|
+
let lastError;
|
|
111
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
112
|
+
const name = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
|
|
113
|
+
const review = {
|
|
114
|
+
id,
|
|
115
|
+
repositoryPath,
|
|
116
|
+
baseRef,
|
|
117
|
+
sourceType,
|
|
118
|
+
sourceRef,
|
|
119
|
+
snapshotData: JSON.stringify(snapshot),
|
|
120
|
+
status: 'in_progress',
|
|
121
|
+
name,
|
|
122
|
+
branch,
|
|
123
|
+
createdAt: nowIso,
|
|
124
|
+
updatedAt: nowIso
|
|
125
|
+
};
|
|
126
|
+
db.exec('BEGIN');
|
|
127
|
+
try {
|
|
128
|
+
created = insertReview(db, review);
|
|
129
|
+
insertReviewFiles(db, id, filePaths);
|
|
130
|
+
db.exec('COMMIT');
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
db.exec('ROLLBACK');
|
|
135
|
+
if (!(error instanceof UniqueNameError)) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
lastError = error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!created) {
|
|
142
|
+
if (userProvidedName) {
|
|
143
|
+
throw new ValidationError(lastError.message);
|
|
144
|
+
}
|
|
145
|
+
throw lastError;
|
|
146
|
+
}
|
|
49
147
|
eventBus?.publish({ type: 'review:created', reviewId: created.id });
|
|
50
148
|
return created;
|
|
51
149
|
}
|
|
52
150
|
export function getReview(db, id) {
|
|
53
151
|
return findReviewById(db, id);
|
|
54
152
|
}
|
|
55
|
-
export function getReviews(db) {
|
|
56
|
-
return listReviews(db);
|
|
153
|
+
export function getReviews(db, filters = {}) {
|
|
154
|
+
return listReviews(db, filters);
|
|
57
155
|
}
|
|
58
156
|
export function setReviewStatus(db, id, status, eventBus) {
|
|
59
157
|
const updated = updateReviewStatusRepo(db, id, status);
|
|
@@ -66,6 +164,7 @@ export function removeReview(db, id, eventBus) {
|
|
|
66
164
|
for (const comment of listCommentsByReview(db, id)) {
|
|
67
165
|
deleteComment(db, comment.id);
|
|
68
166
|
}
|
|
167
|
+
deleteReviewFiles(db, id);
|
|
69
168
|
deleteReviewRepo(db, id);
|
|
70
169
|
eventBus?.publish({ type: 'review:deleted', reviewId: id });
|
|
71
170
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{$ as e,E as t,F as n,H as r,M as i,O as a,S as o,U as s,V as c,Z as l,b as u,g as d,h as f,l as p,rt as m,s as h,v as g,w as _}from"./runtime-core.esm-bundler-Wxr8ezGp.js";import{D as v,S as y,f as b,m as x,t as S,w as C}from"./QBtn-Cf6WtiKt.js";import{d as w}from"./event-BQt_DXaR.js";import{I as T,L as E,_ as D,ot as O,v as k}from"./index-Dl3zmXcR.js";import{n as A}from"./vue-i18n.runtime-DSeWNwrW.js";import{h as j,m as M,r as N,t as P}from"./format-B_1hRYcb.js";function F(e){if(e.length<2||e[0]!==`"`||e[e.length-1]!==`"`)return e;let t=e.slice(1,-1),n=[];for(let e=0;e<t.length;e++){let r=t[e];r===`\\`&&/^[0-7]{3}$/.test(t.slice(e+1,e+4))?(n.push(parseInt(t.slice(e+1,e+4),8)),e+=3):r===`\\`&&t[e+1]===`"`?(n.push(34),e+=1):r===`\\`&&t[e+1]===`\\`?(n.push(92),e+=1):n.push((r??``).charCodeAt(0))}return new TextDecoder(`utf-8`).decode(new Uint8Array(n))}function I(e,t){let n=new EventSource(`/api/events`);for(let r of e)n.addEventListener(r,()=>t());return n.addEventListener(`open`,()=>t()),{close:()=>n.close()}}var L=w({props:[`store`,`barStyle`,`verticalBarStyle`,`horizontalBarStyle`],setup(e){return()=>[g(`div`,{class:e.store.scroll.vertical.barClass.value,style:[e.barStyle,e.verticalBarStyle],"aria-hidden":`true`,onMousedown:e.store.onVerticalMousedown}),g(`div`,{class:e.store.scroll.horizontal.barClass.value,style:[e.barStyle,e.horizontalBarStyle],"aria-hidden":`true`,onMousedown:e.store.onHorizontalMousedown}),s(g(`div`,{ref:e.store.scroll.vertical.ref,class:e.store.scroll.vertical.thumbClass.value,style:e.store.scroll.vertical.style.value,"aria-hidden":`true`}),e.store.thumbVertDir),s(g(`div`,{ref:e.store.scroll.horizontal.ref,class:e.store.scroll.horizontal.thumbClass.value,style:e.store.scroll.horizontal.style.value,"aria-hidden":`true`}),e.store.thumbHorizDir)]}}),R=[`vertical`,`horizontal`],z={vertical:{offset:`offsetY`,scroll:`scrollTop`,dir:`down`,dist:`y`},horizontal:{offset:`offsetX`,scroll:`scrollLeft`,dir:`right`,dist:`x`}},B={prevent:!0,mouse:!0,mouseAllDir:!0},V=e=>e>=250?50:Math.ceil(e/5),H=(e,t)=>t?-e:e;function U(e,t){return R.includes(e)?!0:(console.error(`[QScrollArea]: wrong first param of ${t} (vertical/horizontal)`),!1)}var W=w({name:`QScrollArea`,props:{...k,thumbStyle:Object,verticalThumbStyle:Object,horizontalThumbStyle:Object,barStyle:[Array,String,Object],verticalBarStyle:[Array,String,Object],horizontalBarStyle:[Array,String,Object],verticalOffset:{type:Array,default:()=>[0,0]},horizontalOffset:{type:Array,default:()=>[0,0]},contentStyle:[Array,String,Object],contentActiveStyle:[Array,String,Object],delay:{type:[String,Number],default:1e3},visible:{type:Boolean,default:null},tabindex:[String,Number],onScroll:Function},setup(n,{slots:r,emit:i}){let o=l(!1),s=l(!1),u=l(!1),f={vertical:l(0),horizontal:l(0)},p={vertical:{ref:e(null),position:l(0),size:l(0)},horizontal:{ref:e(null),position:l(0),size:l(0)}},{proxy:m}=d(),v=y(),x=D(n,v),S=null,C,w=e(null),k=h(()=>`q-scrollarea`+(x()?` q-scrollarea--dark`:``));Object.assign(f,{verticalInner:h(()=>f.vertical.value-n.verticalOffset[0]-n.verticalOffset[1]),horizontalInner:h(()=>f.horizontal.value-n.horizontalOffset[0]-n.horizontalOffset[1])}),p.vertical.percentage=h(()=>{let e=p.vertical.size.value-f.vertical.value;if(e<=0)return 0;let t=P(p.vertical.position.value/e,0,1);return Math.round(t*1e4)/1e4}),p.vertical.thumbHidden=h(()=>!(n.visible===null?u.value:n.visible)&&!o.value&&!s.value||p.vertical.size.value<=f.vertical.value+1),p.vertical.thumbStart=h(()=>n.verticalOffset[0]+p.vertical.percentage.value*(f.verticalInner.value-p.vertical.thumbSize.value)),p.vertical.thumbSize=h(()=>Math.round(P(f.verticalInner.value*f.verticalInner.value/p.vertical.size.value,V(f.verticalInner.value),f.verticalInner.value))),p.vertical.style=h(()=>({...n.thumbStyle,...n.verticalThumbStyle,top:`${p.vertical.thumbStart.value}px`,height:`${p.vertical.thumbSize.value}px`,[v.lang.rtl?`left`:`right`]:`${n.horizontalOffset[+!v.lang.rtl]}px`})),p.vertical.thumbClass=h(()=>`q-scrollarea__thumb q-scrollarea__thumb--v absolute-right`+(p.vertical.thumbHidden.value?` q-scrollarea__thumb--invisible`:``)),p.vertical.barClass=h(()=>`q-scrollarea__bar q-scrollarea__bar--v absolute-right`+(p.vertical.thumbHidden.value?` q-scrollarea__bar--invisible`:``)),p.horizontal.percentage=h(()=>{let e=p.horizontal.size.value-f.horizontal.value;if(e<=0)return 0;let t=P(H(p.horizontal.position.value,v.lang.rtl)/e,0,1);return Math.round(t*1e4)/1e4}),p.horizontal.thumbHidden=h(()=>!(n.visible===null?u.value:n.visible)&&!o.value&&!s.value||p.horizontal.size.value<=f.horizontal.value+1),p.horizontal.thumbStart=h(()=>n.horizontalOffset[+!!v.lang.rtl]+p.horizontal.percentage.value*(f.horizontalInner.value-p.horizontal.thumbSize.value)),p.horizontal.thumbSize=h(()=>Math.round(P(f.horizontalInner.value*f.horizontalInner.value/p.horizontal.size.value,V(f.horizontalInner.value),f.horizontalInner.value))),p.horizontal.style=h(()=>({...n.thumbStyle,...n.horizontalThumbStyle,[v.lang.rtl?`right`:`left`]:`${p.horizontal.thumbStart.value}px`,width:`${p.horizontal.thumbSize.value}px`,bottom:`${n.verticalOffset[1]}px`})),p.horizontal.thumbClass=h(()=>`q-scrollarea__thumb q-scrollarea__thumb--h absolute-bottom`+(p.horizontal.thumbHidden.value?` q-scrollarea__thumb--invisible`:``)),p.horizontal.barClass=h(()=>`q-scrollarea__bar q-scrollarea__bar--h absolute-bottom`+(p.horizontal.thumbHidden.value?` q-scrollarea__bar--invisible`:``));let A=h(()=>n.tabindex===void 0?p.vertical.size.value>f.vertical.value+1||p.horizontal.size.value>f.horizontal.value+1?0:void 0:n.tabindex),F=h(()=>p.vertical.thumbHidden.value&&p.horizontal.thumbHidden.value?n.contentStyle:n.contentActiveStyle);function I(){return{verticalPosition:p.vertical.position.value,verticalPercentage:p.vertical.percentage.value,verticalSize:p.vertical.size.value,verticalContainerSize:f.vertical.value,verticalContainerInnerSize:f.verticalInner.value,horizontalPosition:p.horizontal.position.value,horizontalPercentage:p.horizontal.percentage.value,horizontalSize:p.horizontal.size.value,horizontalContainerSize:f.horizontal.value,horizontalContainerInnerSize:f.horizontalInner.value}}let R=O(()=>{let e=I();e.ref=m,i(`scroll`,e)},0);function W(e,t,n){U(e,`setScrollPosition`)&&(e===`vertical`?E:T)(w.value,t,n)}function G({height:e,width:t}){let n=!1;f.vertical.value!==e&&(f.vertical.value=e,n=!0),f.horizontal.value!==t&&(f.horizontal.value=t,n=!0),n&&X()}function K({position:e}){let t=!1;p.vertical.position.value!==e.top&&(p.vertical.position.value=e.top,t=!0),p.horizontal.position.value!==e.left&&(p.horizontal.position.value=e.left,t=!0),t&&X()}function q({height:e,width:t}){p.horizontal.size.value!==t&&(p.horizontal.size.value=t,X()),p.vertical.size.value!==e&&(p.vertical.size.value=e,X())}function J(e,t){let n=p[t];if(e.isFirst){if(n.thumbHidden.value)return;C=t===`horizontal`?H(n.position.value,v.lang.rtl):n.position.value,s.value=!0}else if(!s.value)return;e.isFinal&&(s.value=!1);let r=z[t],i=(n.size.value-f[t].value)/(f[t+`Inner`].value-n.thumbSize.value),a=e.distance[r.dist],o=(e.direction===r.dir?1:-1)*(t===`horizontal`&&v.lang.rtl?-1:1),c=C+o*a*i;Z(t===`horizontal`?H(c,v.lang.rtl):c,t)}function Y(e,t){let r=p[t];if(!r.thumbHidden.value){let i=t===`horizontal`&&v.lang.rtl,a=t===`vertical`?n.verticalOffset[0]:n.horizontalOffset[+!!i],o=(i?f.horizontal.value-e.offsetX:e[z[t].offset])-a,s=r.thumbStart.value-a;if(o<s||o>s+r.thumbSize.value){let e=o-r.thumbSize.value/2,n=P(e/(f[t+`Inner`].value-r.thumbSize.value),0,1);Z(i?H(n*Math.max(0,r.size.value-f[t].value),v.lang.rtl):n*Math.max(0,r.size.value-f[t].value),t)}r.ref.value!==null&&r.ref.value.dispatchEvent(new MouseEvent(e.type,e))}}function X(){o.value=!0,S!==null&&clearTimeout(S),S=setTimeout(()=>{S=null,o.value=!1},n.delay),n.onScroll!==void 0&&R()}function Z(e,t){w.value[z[t].scroll]=e}let Q=null;function ee(){Q!==null&&clearTimeout(Q),Q=setTimeout(()=>{Q=null,u.value=!0},v.platform.is.ios?50:0)}function te(){Q!==null&&(clearTimeout(Q),Q=null),u.value=!1}let $=null;c(()=>v.lang.rtl,(e,t)=>{w.value!==null&&T(w.value,H(H(p.horizontal.position.value,t),e))}),a(()=>{$={top:p.vertical.position.value,left:p.horizontal.position.value}}),_(()=>{if($===null)return;let e=w.value;e!==null&&(T(e,$.left),E(e,$.top))}),t(R.cancel),Object.assign(m,{getScrollTarget:()=>w.value,getScroll:I,getScrollPosition:()=>({top:p.vertical.position.value,left:p.horizontal.position.value}),getScrollPercentage:()=>({top:p.vertical.percentage.value,left:p.horizontal.percentage.value}),setScrollPosition:W,setScrollPercentage(e,t,n){if(!U(e,`setScrollPercentage`))return;let r=t*(p[e].size.value-f[e].value);W(e,e===`horizontal`?H(r,v.lang.rtl):r,n)}});let ne={scroll:p,thumbVertDir:[[N,e=>{J(e,`vertical`)},void 0,{vertical:!0,...B}]],thumbHorizDir:[[N,e=>{J(e,`horizontal`)},void 0,{horizontal:!0,...B}]],onVerticalMousedown(e){Y(e,`vertical`)},onHorizontalMousedown(e){Y(e,`horizontal`)}};return()=>g(`div`,{class:k.value,onMouseenter:ee,onMouseleave:te},[g(`div`,{ref:w,class:`q-scrollarea__container scroll relative-position fit hide-scrollbar`,tabindex:A.value},[g(`div`,{class:`q-scrollarea__content absolute`,style:F.value},b(r.default,[g(M,{debounce:0,onResize:q})])),g(j,{axis:`both`,onScroll:K})]),g(M,{debounce:0,onResize:G}),g(L,{store:ne,barStyle:n.barStyle,verticalBarStyle:n.verticalBarStyle,horizontalBarStyle:n.horizontalBarStyle})])}}),G=f({__name:`CancelButton`,setup(e){let{t}=A();return(e,n)=>(i(),p(S,o({padding:`2px 8px`},e.$attrs,{dense:``,flat:``,color:`warning`,label:m(t)(`reviews.comments.cancel`)}),null,16,[`label`]))}}),K=f({__name:`CreateButton`,setup(e){let{t}=A();return(e,n)=>(i(),p(S,o({padding:`2px 8px`,label:m(t)(`reviews.create.submit`)},e.$attrs,{unelevated:``,dense:``,color:`accent`}),null,16,[`label`]))}}),q=w({name:`QPage`,props:{padding:Boolean,styleFn:Function},setup(e,{slots:t}){let n=y(),r=u(v,C);if(r===C)return console.error(`QPage needs to be a deep child of QLayout`),C;if(u(`_q_pc_`,C)===C)return console.error(`QPage needs to be child of QPageContainer`),C;let i=h(()=>{let t=(r.header.space?r.header.size:0)+(r.footer.space?r.footer.size:0);if(typeof e.styleFn==`function`){let i=r.isContainer.value?r.containerHeight.value:n.screen.height;return e.styleFn(t,i)}return{minHeight:r.isContainer.value?r.containerHeight.value-t+`px`:n.screen.height===0?t===0?`100vh`:`calc(100vh - ${t}px)`:n.screen.height-t+`px`}}),a=h(()=>`q-page${e.padding?` q-layout-padding`:``}`);return()=>g(`main`,{class:a.value,style:i.value},x(t.default))}}),J=f({__name:`AppPage`,setup(e){function t(e,t){return{height:`${t-e}px`}}return(e,a)=>(i(),p(q,{"style-fn":t},{default:r(()=>[n(e.$slots,`default`)]),_:3}))}});export{I as a,W as i,K as n,F as o,G as r,J as t};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{$ as e,A as t,H as n,M as r,N as i,S as a,U as o,V as s,Z as c,b as l,c as u,d,g as f,h as p,k as m,l as h,m as g,r as _,rt as v,s as y,v as b,x,yt as S,z as C}from"./runtime-core.esm-bundler-Wxr8ezGp.js";import{C as w,D as T,L as E,S as D,T as ee,c as O,f as te,j as k,m as A,t as j,w as M}from"./QBtn-Cf6WtiKt.js";import{d as N,f as P}from"./event-BQt_DXaR.js";import{H as F,J as ne,K as re,V as ie,Z as ae,d as oe,g as se,h as I,m as ce,n as L,q as le,y as ue}from"./index-Dl3zmXcR.js";import{n as de}from"./vue-i18n.runtime-DSeWNwrW.js";import{n as R,r as z}from"./pinia-DK4OjQ7o.js";import{a as B,i as V,n as fe,r as H,t as pe}from"./client-De34x_P_.js";import{r as me}from"./format-B_1hRYcb.js";import{a as he,n as ge,o as U,r as _e}from"./AppPage-VD8mwL62.js";function W(e){return{...e,oldPath:U(e.oldPath),newPath:U(e.newPath)}}function G(e,t){let n=new Map(e.map(e=>[e.newPath||e.oldPath,e]));return t.map(e=>{let t=n.get(e.newPath||e.oldPath);return t&&JSON.stringify(t)===JSON.stringify(e)?t:e})}var ve=R(`diff`,()=>{let e=c([]),t=c([]),n=c(!1),r=c(null),i=y(()=>[...e.value,...t.value].map(e=>e.newPath||e.oldPath));async function a(){n.value=!0,r.value=null;try{let[n,r]=await Promise.all([H(`/api/diff/staged`),H(`/api/diff/unstaged`)]);e.value=G(e.value,n.map(W)),t.value=G(t.value,r.map(W))}catch(n){e.value=[],t.value=[],r.value=n instanceof Error?n.message:String(n)}finally{n.value=!1}}return{stagedFiles:e,unstagedFiles:t,loading:n,error:r,changedPaths:i,fetchDiff:a}}),K=R(`active-review`,()=>{let e=c(null),t=c([]),n=c(!1),r=y(()=>{let e=new Map;for(let n of t.value){let t=e.get(n.filePath);t?t.push(n):e.set(n.filePath,[n])}return e});async function i(){n.value=!0;try{let n=(await H(`/api/reviews`)).filter(e=>e.status===`in_progress`).sort((e,t)=>t.createdAt.localeCompare(e.createdAt));e.value=n[0]??null,t.value=e.value?await H(`/api/reviews/${e.value.id}/comments`):[]}finally{n.value=!1}}function a(n){e.value=n,t.value=[]}async function o(t){e.value&&(await V(`/api/reviews/${e.value.id}`,{status:t}),await i())}async function s(n){if(!e.value)throw Error(`No active review`);let r=await B(`/api/reviews/${e.value.id}/comments`,n);return t.value=[...t.value,r],r}async function l(e,n){let r=await V(`/api/comments/${e}`,{content:n});t.value=t.value.map(t=>t.id===e?r:t)}async function u(e){await fe(`/api/comments/${e}`),t.value=t.value.filter(t=>t.id!==e)}async function d(e){let n=await V(`/api/comments/${e}/resolve`);t.value=t.value.map(t=>t.id===e?n:t)}async function f(e){let n=await V(`/api/comments/${e}/unresolve`);t.value=t.value.map(t=>t.id===e?n:t)}return{activeReview:e,comments:t,loading:n,commentsByFile:r,refresh:i,setActiveReview:a,setStatus:o,createComment:s,editComment:l,deleteComment:u,resolveComment:d,unresolveComment:f}}),ye=p({__name:`SearchInput`,props:{modelValue:{},modelModifiers:{}},emits:[`update:modelValue`],setup(e){let t=C(e,`modelValue`);return(e,i)=>(r(),h(L,a({modelValue:t.value,"onUpdate:modelValue":i[0]||=e=>t.value=e},e.$attrs,{dense:``,outlined:``,clearable:``}),{prepend:n(()=>[g(O,{name:`search`,size:`xs`})]),_:1},16,[`modelValue`]))}}),be=N({name:`QItemLabel`,props:{overline:Boolean,caption:Boolean,header:Boolean,lines:[Number,String]},setup(e,{slots:t}){let n=y(()=>Number.parseInt(e.lines,10)),r=y(()=>`q-item__label`+(e.overline?` q-item__label--overline text-overline`:``)+(e.caption?` q-item__label--caption text-caption`:``)+(e.header?` q-item__label--header`:``)+(n.value===1?` ellipsis`:``)),i=y(()=>e.lines!==void 0&&n.value>1?{overflow:`hidden`,display:`-webkit-box`,"-webkit-box-orient":`vertical`,"-webkit-line-clamp":n.value}:null);return()=>b(`div`,{style:i.value,class:r.value},A(t.default))}}),xe=[`top`,`right`,`bottom`,`left`],q={type:{type:String,default:`a`},outline:Boolean,push:Boolean,flat:Boolean,unelevated:Boolean,color:String,textColor:String,glossy:Boolean,square:Boolean,padding:String,label:{type:[String,Number],default:``},labelPosition:{type:String,default:`right`,validator:e=>xe.includes(e)},externalLabel:Boolean,hideLabel:{type:Boolean},labelClass:[Array,String,Object],labelStyle:[Array,String,Object],disable:Boolean,tabindex:[Number,String]},Se=[`label`],Ce=Object.keys(q).filter(e=>!Se.includes(e)&&j.props[e]!==void 0);function we(e){let t={};for(let n of Ce)t[n]=e[n];return t}function Te(e,t){return{formClass:y(()=>`q-fab--form-${e.square?`square`:`rounded`}`),stacked:y(()=>!e.externalLabel&&[`top`,`bottom`].includes(e.labelPosition)),labelProps:y(()=>{if(e.externalLabel){let n=e.hideLabel===null?!t.value:e.hideLabel;return{action:`push`,data:{class:[e.labelClass,`q-fab__label q-tooltip--style q-fab__label--external q-fab__label--external-${e.labelPosition}`+(n?` q-fab__label--external-hidden`:``)],style:e.labelStyle}}}return{action:[`left`,`top`].includes(e.labelPosition)?`unshift`:`push`,data:{class:[e.labelClass,`q-fab__label q-fab__label--internal q-fab__label--internal-${e.labelPosition}`+(e.hideLabel?` q-fab__label--internal-hidden`:``)],style:e.labelStyle}}})}}var J={hover:Boolean,hoverDelay:{type:Number,default:0},hoverHideDelay:{type:Number,default:150}};function Y({props:e,canShow:t,show:n,canHide:r,hide:i}){let{removeTimeout:a,registerTimeout:o}=ae();function s(r){e.hover===!0&&r.pointerType!==`touch`&&(a(),t(r)&&(e.hoverDelay>0?o(()=>{n(r)},e.hoverDelay):n(r)))}function c(t){a(),r(t)&&o(()=>{i(t)},e.hoverHideDelay)}function l(t){e.hover===!0&&t.pointerType!==`touch`&&c(t)}function u(e){e.pointerType!==`touch`&&a()}return{clearHoverTimer:a,hoverShow:s,hoverHide:l,scheduleHoverHide:c,onHoverContentEnter:u}}var Ee=[`up`,`right`,`down`,`left`],De=[`left`,`center`,`right`],Oe=180,ke=N({name:`QFab`,props:{...q,...ne,icon:String,activeIcon:String,hideIcon:Boolean,hideLabel:{...q.hideLabel,default:null},direction:{type:String,default:`right`,validator:e=>Ee.includes(e)},...J,persistent:Boolean,stagger:{type:Number,default:40},verticalActionsAlign:{type:String,default:`center`,validator:e=>De.includes(e)}},emits:le,setup(t,{slots:n}){let r=e(null),a=c(t.modelValue===!0),o=oe(),l=D(),{formClass:u,stacked:d,labelProps:f}=Te(t,a),p=y(()=>!t.persistent),{show:m,hide:h,toggle:g}=re({showing:a,hideOnRouteChange:p}),_=0,{clearHoverTimer:v,hoverShow:x,hoverHide:S}=Y({props:t,canShow:()=>!a.value,show:e=>{_=Date.now(),m(e)},canHide:()=>a.value,hide:h});s(a,v);function C(e){a.value&&Date.now()-_<Oe||g(e)}let w=y(()=>({opened:a.value})),T=y(()=>`q-fab z-fab row inline justify-center q-fab--align-${t.verticalActionsAlign} ${u.value}`+(a.value?` q-fab--opened`:` q-fab--closed`)),E=y(()=>`q-fab__actions flex no-wrap inline q-fab__actions--${t.direction} q-fab__actions--${a.value?`opened`:`closed`}`),k=y(()=>t.hover?{onPointerenter:x,onPointerleave:S}:{}),M=y(()=>{let e={id:o.value,style:`--q-fab-stagger: ${t.stagger}ms`};return a.value||(e[`aria-hidden`]=`true`),e}),N=y(()=>`q-fab__icon-holder q-fab__icon-holder--${a.value?`opened`:`closed`}`);function P(e,r){let i=n[e],a=`q-fab__${e} absolute-full`;return i===void 0?b(O,{class:a,name:t[r]||l.iconSet.fab[r]}):b(`div`,{class:a},i(w.value))}function F(){let e=[];return t.hideIcon||e.push(b(`div`,{class:N.value},[P(`icon`,`icon`),P(`active-icon`,`activeIcon`)])),(t.label!==``||n.label!==void 0)&&e[f.value.action](b(`div`,f.value.data,n.label===void 0?[t.label]:n.label(w.value))),te(n.tooltip,e)}return i(ee,{showing:a,onChildClick(e){h(e),e?.qAvoidFocus!==!0&&r.value?.$el.focus()}}),()=>b(`div`,{class:T.value,...k.value},[b(j,{ref:r,class:u.value,...we(t),noWrap:!0,stack:d.value,noCaps:!0,fab:!0,"aria-expanded":a.value?`true`:`false`,"aria-controls":o.value,onClick:C},F),b(`div`,{class:E.value,...M.value},A(n.default))])}}),Ae={position:{type:String,default:`bottom-right`,validator:e=>[`top-right`,`top-left`,`bottom-right`,`bottom-left`,`top`,`right`,`bottom`,`left`].includes(e)},offset:{type:Array,validator:e=>e.length===2},expand:Boolean};function je(){let{props:e}=f(),t=D(),n=l(T,M);if(n===M)return console.error(`QPageSticky needs to be child of QLayout`),M;let r=y(()=>{let t=e.position;return{top:t.includes(`top`),right:t.includes(`right`),bottom:t.includes(`bottom`),left:t.includes(`left`),vertical:t===`top`||t===`bottom`,horizontal:t===`left`||t===`right`}}),i=y(()=>n.header.offset),a=y(()=>n.right.offset),o=y(()=>n.footer.offset),s=y(()=>n.left.offset),c=y(()=>{let n=0,c=0,l=r.value,u=t.lang.rtl?-1:1;l.top&&i.value!==0?c=`${i.value}px`:l.bottom&&o.value!==0&&(c=`${-o.value}px`),l.left&&s.value!==0?n=`${u*s.value}px`:l.right&&a.value!==0&&(n=`${-u*a.value}px`);let d={transform:`translate(${n}, ${c})`};return e.offset&&(d.margin=`${e.offset[1]}px ${e.offset[0]}px`),l.vertical?(s.value!==0&&(d[t.lang.rtl?`right`:`left`]=`${s.value}px`),a.value!==0&&(d[t.lang.rtl?`left`:`right`]=`${a.value}px`)):l.horizontal&&(i.value!==0&&(d.top=`${i.value}px`),o.value!==0&&(d.bottom=`${o.value}px`)),d}),u=y(()=>`q-page-sticky row flex-center fixed-${e.position} q-page-sticky--${e.expand?`expand`:`shrink`}`);function d(t){let n=A(t.default);return b(`div`,{class:u.value,style:c.value},e.expand?n:[b(`div`,n)])}return{$layout:n,getStickyContent:d}}var Me=N({name:`QPageSticky`,props:Ae,setup(e,{slots:t}){let{getStickyContent:n}=je();return()=>n(t)}});function X(e){return e===!1?0:e===!0||e===void 0?1:Number.parseInt(e,10)||0}function Z(e){let t=e.currentTarget,n=t.__qclosepopup;n!==0&&setTimeout(()=>{let r=F(t);r!==void 0&&ie(r,e,n)},0)}function Q(e){k(e,13)&&Z(e)}var Ne=P({name:`close-popup`,beforeMount(e,{value:t}){e.__qclosepopup=X(t),e.addEventListener(`click`,Z),e.addEventListener(`keyup`,Q)},updated(e,{value:t,oldValue:n}){t!==n&&e.__qclosepopup!==void 0&&(e.__qclosepopup=X(t))},beforeUnmount(e){e.__qclosepopup!==void 0&&(e.removeEventListener(`click`,Z),e.removeEventListener(`keyup`,Q),e.__qclosepopup=void 0)}});function Pe(e){let t=new URLSearchParams;e.search&&t.set(`search`,e.search),e.createdFrom&&t.set(`createdFrom`,e.createdFrom),e.createdTo&&t.set(`createdTo`,e.createdTo),e.files&&e.files.length>0&&t.set(`files`,e.files.join(`,`));let n=t.toString();return n?`?${n}`:``}var $=R(`reviews`,()=>{let e=c([]),t=c(!1),n=c(null);async function r(r={}){t.value=!0,n.value=null;try{e.value=await H(`/api/reviews${Pe(r)}`)}catch(t){e.value=[],n.value=t instanceof Error?t.message:String(t)}finally{t.value=!1}}async function i(t){let n=await B(`/api/reviews`,t);return e.value=[n,...e.value],n}return{reviews:e,loading:t,error:n,fetchReviews:r,createReview:i}}),Fe={class:`text-h6`},Ie=w(p({__name:`ReviewCreateDialog`,props:{modelValue:{type:Boolean,required:!0},modelModifiers:{}},emits:x([`created`,`hide`],[`update:modelValue`]),setup(e,{emit:t}){let i=C(e,`modelValue`),a=t,{t:s}=de(),l=$(),d=c(``),f=c(!1),p=c(null);function m(){d.value=``,f.value=!1,p.value=null}async function _(){f.value=!0,p.value=null;try{let e=d.value.trim(),t=await l.createReview(e?{name:e}:{});i.value=!1,m(),a(`created`,t)}catch(e){p.value=e instanceof pe||e instanceof Error?e.message:String(e)}finally{f.value=!1}}function y(){m(),a(`hide`)}return(e,t)=>(r(),h(ue,{modelValue:i.value,"onUpdate:modelValue":t[1]||=e=>i.value=e,onHide:y},{default:n(()=>[g(se,{class:`review-create-dialog`},{default:n(()=>[g(I,null,{default:n(()=>[u(`div`,Fe,S(v(s)(`reviews.create.title`)),1)]),_:1}),g(I,{class:`q-gutter-y-md`},{default:n(()=>[g(L,{modelValue:d.value,"onUpdate:modelValue":t[0]||=e=>d.value=e,dense:``,outlined:``,label:v(s)(`reviews.create.nameLabel`),hint:v(s)(`reviews.create.nameHint`),error:!!p.value,"error-message":p.value??void 0,onKeyup:E(_,[`enter`])},null,8,[`modelValue`,`label`,`hint`,`error`,`error-message`])]),_:1}),g(ce,{align:`right`},{default:n(()=>[o(g(_e,null,null,512),[[Ne]]),g(ge,{loading:f.value,onClick:_},null,8,[`loading`])]),_:1})]),_:1})]),_:1},8,[`modelValue`]))}}),[[`__scopeId`,`data-v-c9b736c2`]]),Le=p({__name:`CreateReviewFabButton`,emits:[`created`],setup(e,{emit:i}){let a=i,s=K(),l=ve(),{activeReview:u}=z(s),{changedPaths:f}=z(l),p=c(!1),h=c(!1),v=c([18,18]),b=c(!1),x=y(()=>f.value.length===0||u.value!==null);function S(){l.fetchDiff(),s.refresh()}m(S);let C=he([`files:changed`,`review:created`,`review:updated`,`review:deleted`],S);t(()=>C.close());function w(e){b.value=e.isFirst!==!0&&e.isFinal!==!0,v.value=[v.value[0]-(e.delta?.x??0),v.value[1]-(e.delta?.y??0)]}function T(e){s.setActiveReview(e),a(`created`,e)}return(e,t)=>(r(),d(_,null,[g(Me,{position:`bottom-right`,offset:v.value},{default:n(()=>[o(g(ke,{modelValue:h.value,"onUpdate:modelValue":t[0]||=e=>h.value=e,icon:`add`,color:`accent`,disable:x.value||b.value,onClick:t[1]||=e=>p.value=!0},null,8,[`modelValue`,`disable`]),[[me,w,void 0,{prevent:!0,mouse:!0}]])]),_:1},8,[`offset`]),g(Ie,{modelValue:p.value,"onUpdate:modelValue":t[2]||=e=>p.value=e,onCreated:T,onHide:t[3]||=e=>h.value=!1},null,8,[`modelValue`])],64))}});export{be as a,ve as c,J as i,$ as n,ye as o,Y as r,K as s,Le as t};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.review-create-dialog[data-v-c9b736c2]{width:420px;max-width:90vw}
|