@kubohiroya/turbowarp-plus-editor-git 0.16.0 → 0.17.0
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/README.ja.md +14 -0
- package/README.md +14 -0
- package/dist/close-button.d.ts +6 -0
- package/dist/filesystem.d.ts +2 -2
- package/dist/github-pull-requests.d.ts +20 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +181 -15
- package/dist/local-operations.d.ts +8 -1
- package/dist/messages.d.ts +8 -0
- package/dist/{project-asset-paths-KV715791.js → project-asset-paths-nL5sV35A.js} +653 -1
- package/dist/{project-comparison-MU0qD2wo.js → project-comparison-DjMlwyp8.js} +35 -656
- package/dist/project-comparison.d.ts +1 -0
- package/dist/project-source-diff.js +2 -1
- package/dist/pull-request-panel.d.ts +25 -1
- package/dist/remote-operations.d.ts +23 -0
- package/dist/{repository-BYno-5f8.js → repository-xIv0iZkj.js} +346 -7
- package/dist/repository.d.ts +32 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +4 -0
- package/package.json +1 -1
- package/src/close-button.tsx +10 -0
- package/src/filesystem.ts +2 -1
- package/src/github-pull-requests.ts +7 -0
- package/src/index.tsx +33 -4
- package/src/local-operations.ts +23 -3
- package/src/messages.ts +8 -0
- package/src/project-comparison.tsx +4 -2
- package/src/pull-request-panel.tsx +125 -14
- package/src/remote-operations.ts +84 -3
- package/src/repository.ts +133 -1
- package/src/style.css +4 -0
- package/test/smart-http-fixture.js +20 -0
package/src/repository.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as git from 'isomorphic-git';
|
|
2
2
|
import {createTwoFilesPatch} from 'diff';
|
|
3
3
|
import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
|
|
4
|
-
import {runRemoteOperation} from './remote-operations.js';
|
|
4
|
+
import {runPullRequestFetch, runRemoteOperation} from './remote-operations.js';
|
|
5
|
+
import {createProjectSourceDiff} from './project-source-patch.js';
|
|
6
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
5
7
|
import {runLocalOperation} from './local-operations.js';
|
|
6
8
|
import type {LocalOperation} from './local-operations.js';
|
|
7
9
|
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
@@ -9,6 +11,8 @@ import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from
|
|
|
9
11
|
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
10
12
|
|
|
11
13
|
export {createReadOnlyFs, GitPanelError} from './filesystem.js';
|
|
14
|
+
export {pullRequestRef} from './remote-operations.js';
|
|
15
|
+
export type {PullRequestFetch} from './remote-operations.js';
|
|
12
16
|
export type ChangeGroup = 'staged' | 'unstaged' | 'untracked' | 'commit';
|
|
13
17
|
export interface FileChange {
|
|
14
18
|
path: string;
|
|
@@ -21,6 +25,22 @@ export interface FileChange {
|
|
|
21
25
|
/** For `commit` changes, the commit compared with its first parent. */
|
|
22
26
|
commit?: string;
|
|
23
27
|
}
|
|
28
|
+
/** A version of the pull request's base to compare with: GitHub's base commit when known, else a branch on origin. */
|
|
29
|
+
export interface PullRequestBase {
|
|
30
|
+
/** The base commit GitHub reported, used when the fetch brought it. */
|
|
31
|
+
sha?: string;
|
|
32
|
+
/** The base branch, read from `refs/remotes/origin/<branch>` when `sha` is missing or not local. */
|
|
33
|
+
branch?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PullRequestFiles {
|
|
36
|
+
/** The base that was compared: the branch it was named by, if any, and its commit. */
|
|
37
|
+
base: {ref: string | null; sha: string};
|
|
38
|
+
head: string;
|
|
39
|
+
/** The merge base of `base.sha` and `head`, like GitHub's three-dot comparison; `null` for unrelated histories. */
|
|
40
|
+
mergeBase: string | null;
|
|
41
|
+
/** Every changed path from the merge base to the head, with project data as a project-source diff. */
|
|
42
|
+
files: ProjectSourceReviewFile[];
|
|
43
|
+
}
|
|
24
44
|
export interface Commit {
|
|
25
45
|
oid: string;
|
|
26
46
|
message: string;
|
|
@@ -105,6 +125,9 @@ export interface FileDiff {
|
|
|
105
125
|
tooLarge: boolean;
|
|
106
126
|
}
|
|
107
127
|
const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
128
|
+
const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
|
|
129
|
+
const validBranch = (name: string) => /^[^\s~^:?*[\\]+$/u.test(name) && !name.startsWith('/') && !name.endsWith('/') &&
|
|
130
|
+
!name.endsWith('.lock') && !name.includes('..') && !name.includes('//') && !name.includes('@{');
|
|
108
131
|
const textLimit = 1024 * 1024;
|
|
109
132
|
const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
|
|
110
133
|
// Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
|
|
@@ -551,6 +574,100 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
551
574
|
await fs.checkReads();
|
|
552
575
|
return {before: before.source, after: after.source, assetsDirectory: {before: before.assetsDirectory, after: after.assetsDirectory}};
|
|
553
576
|
};
|
|
577
|
+
const hasCommit = async (oid: string) => {
|
|
578
|
+
try {
|
|
579
|
+
await git.readCommit({...options, cache, oid});
|
|
580
|
+
return true;
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
type TreeChange = {path: string; before: Entry | null; after: Entry | null};
|
|
586
|
+
/** Lists the paths that differ between two trees, skipping subtrees with the same object. */
|
|
587
|
+
const treeChanges = async (before: string, after: string): Promise<TreeChange[]> => {
|
|
588
|
+
const file = async (entry: git.WalkerEntry | null) => entry && await entry.type() !== 'tree' ?
|
|
589
|
+
{oid: await entry.oid(), mode: await entry.mode()} : null;
|
|
590
|
+
const changes = await git.walk({...options, cache, trees: [git.TREE({ref: before}), git.TREE({ref: after})],
|
|
591
|
+
map: async (path, [a, b]) => {
|
|
592
|
+
if (path === '.') return;
|
|
593
|
+
if (a && b && await a.oid() === await b.oid() && await a.mode() === await b.mode()) return null;
|
|
594
|
+
const change = {path, before: await file(a), after: await file(b)};
|
|
595
|
+
// A tree on either side is walked on; a file on the other side is a change of its own.
|
|
596
|
+
return change.before || change.after ? change : undefined;
|
|
597
|
+
}}) as TreeChange[];
|
|
598
|
+
return changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
599
|
+
};
|
|
600
|
+
const readBlobText = async (entry: Entry | null) => {
|
|
601
|
+
if (!entry) return '{"targets":[]}\n';
|
|
602
|
+
if (entry.mode !== 0o100644 && entry.mode !== 0o100755) throw new GitPanelError('unsupported');
|
|
603
|
+
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
604
|
+
if (blob.byteLength > projectSourceLimit) throw new GitPanelError('large');
|
|
605
|
+
return new TextDecoder('utf-8', {fatal: true}).decode(blob);
|
|
606
|
+
};
|
|
607
|
+
const readTreeAsset = async (ref: string, folder: string[], md5ext: string, signal: AbortSignal) => {
|
|
608
|
+
if (signal.aborted) return null;
|
|
609
|
+
const source: Source = {kind: 'tree', ref};
|
|
610
|
+
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
611
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
612
|
+
if (!directory || signal.aborted) return null;
|
|
613
|
+
const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
|
|
614
|
+
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
615
|
+
};
|
|
616
|
+
const pullRequestAssets = (versions: Record<ProjectVersion, {ref: string; path: string} | null>): ProjectAssetLoader =>
|
|
617
|
+
({version, md5ext, signal}) => {
|
|
618
|
+
const location = versions[version];
|
|
619
|
+
if (!location || !isAssetName(md5ext) || signal.aborted) return Promise.resolve(null);
|
|
620
|
+
return serial(() => readTreeAsset(location.ref, pathParts(location.path).slice(0, -1), md5ext, signal)).catch(() => null);
|
|
621
|
+
};
|
|
622
|
+
/** Compares a fetched pull request head with its merge base, from local Git objects only. */
|
|
623
|
+
const readPullRequest = async (head: string, base: PullRequestBase): Promise<PullRequestFiles> => {
|
|
624
|
+
if (!isSha(head) || !await hasCommit(head)) throw new GitPanelError('pullRequestNotFound');
|
|
625
|
+
let sha: string | undefined;
|
|
626
|
+
if (isSha(base.sha) && await hasCommit(base.sha)) sha = base.sha;
|
|
627
|
+
else if (base.branch !== undefined && validBranch(base.branch)) {
|
|
628
|
+
try {
|
|
629
|
+
sha = await git.resolveRef({...options, ref: 'refs/remotes/origin/' + base.branch});
|
|
630
|
+
} catch {
|
|
631
|
+
// Reported below.
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (!sha || !await hasCommit(sha)) throw new GitPanelError('baseMissing');
|
|
635
|
+
const [mergeBase = null] = await git.findMergeBase({...options, cache, oids: [sha, head]}) as string[];
|
|
636
|
+
const changes = await treeChanges(mergeBase ?? emptyTree, head);
|
|
637
|
+
// Pair a removed and an added file with the same content as a rename, like GitHub's exact renames.
|
|
638
|
+
const added = new Map<string, TreeChange[]>();
|
|
639
|
+
for (const change of changes) if (!change.before && change.after) added.set(change.after.oid, [...added.get(change.after.oid) ?? [], change]);
|
|
640
|
+
const renamed = new Map<TreeChange, TreeChange>();
|
|
641
|
+
for (const change of changes) {
|
|
642
|
+
const candidates = change.before && !change.after ? added.get(change.before.oid) : undefined;
|
|
643
|
+
const target = candidates?.find(candidate => !renamed.has(candidate));
|
|
644
|
+
if (target) renamed.set(target, change);
|
|
645
|
+
}
|
|
646
|
+
const removedByRename = new Set(renamed.values());
|
|
647
|
+
const files: ProjectSourceReviewFile[] = [];
|
|
648
|
+
for (const change of changes) {
|
|
649
|
+
if (removedByRename.has(change)) continue;
|
|
650
|
+
const from = renamed.get(change);
|
|
651
|
+
const before = from?.before ?? change.before;
|
|
652
|
+
const previousPath = from?.path;
|
|
653
|
+
const status = !before ? 'added' : !change.after ? 'removed' : 'updated';
|
|
654
|
+
if (change.path.split('/').pop() !== 'project.source.json') {
|
|
655
|
+
files.push({path: change.path, previousPath, status});
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const patch = createProjectSourceDiff(await readBlobText(before), await readBlobText(change.after));
|
|
660
|
+
const loadAsset = pullRequestAssets({
|
|
661
|
+
before: before && mergeBase ? {ref: mergeBase, path: previousPath ?? change.path} : null,
|
|
662
|
+
after: change.after ? {ref: head, path: change.path} : null
|
|
663
|
+
});
|
|
664
|
+
files.push({path: change.path, previousPath, patch, loadAsset});
|
|
665
|
+
} catch {
|
|
666
|
+
files.push({path: change.path, previousPath, status, unavailable: true});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return {base: {ref: base.branch ?? null, sha}, head, mergeBase, files};
|
|
670
|
+
};
|
|
554
671
|
const directoryAt = async (path: string) => {
|
|
555
672
|
let directory = root;
|
|
556
673
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -595,6 +712,21 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
595
712
|
throw error;
|
|
596
713
|
}
|
|
597
714
|
}),
|
|
715
|
+
/**
|
|
716
|
+
* Fetches a pull request's head (`refs/pull/<number>/head`, which also covers pull requests from forks) and every
|
|
717
|
+
* branch over the connection; see `runPullRequestFetch`. Only Git objects and refs are written.
|
|
718
|
+
*/
|
|
719
|
+
fetchPullRequest: (number: number, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) =>
|
|
720
|
+
serial(async () => {
|
|
721
|
+
const result = await runPullRequestFetch(root, number, getSession, guard, signal);
|
|
722
|
+
cache = {};
|
|
723
|
+
return result;
|
|
724
|
+
}),
|
|
725
|
+
/**
|
|
726
|
+
* Reads the changes of a fetched pull request head against a base, from local Git objects: the changed paths
|
|
727
|
+
* from the merge base to the head, and each project file's diff with assets read from the same commits.
|
|
728
|
+
*/
|
|
729
|
+
readPullRequest: (head: string, base: PullRequestBase) => serial(() => readPullRequest(head, base)),
|
|
598
730
|
/** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
|
|
599
731
|
localOperation: (operation: LocalOperation, guard: () => void) =>
|
|
600
732
|
serial(() => runLocalOperation(root, operation, guard)),
|
package/src/style.css
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
.twgit-panel { width: 94vw; height: 90vh; display: flex; flex-direction: column; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 8px; box-shadow: 0 12px 48px #0005; font: 14px/1.5 system-ui, sans-serif; overflow: hidden; }
|
|
3
3
|
.twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
|
|
4
4
|
.twgit-panel > header h2 { margin: 0 auto 0 0; font-size: 18px; }
|
|
5
|
+
.twgit-panel .twgit-close, .twgit-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0;
|
|
6
|
+
border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
7
|
+
.twgit-panel .twgit-close:hover, .twgit-close:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
8
|
+
.twgit-panel .twgit-close:focus-visible, .twgit-close:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
|
5
9
|
.twgit-panel h3 { font-size: 14px; margin: 12px 0 5px; }
|
|
6
10
|
.twgit-panel button { color: inherit; background: Canvas; border: 1px solid GrayText; border-radius: 4px; padding: 4px 8px; cursor: pointer; font: inherit; text-align: start; }
|
|
7
11
|
.twgit-panel button:disabled { opacity: .5; cursor: default; }
|
|
@@ -176,3 +176,23 @@ export function adapterOptions(fixture, bearer = 'fixture-write') {
|
|
|
176
176
|
fetch: (url, init) => fetch(url, {...init, headers: {...Object.fromEntries(init.headers), Origin: 'https://editor.example.invalid'}})
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Publishes a pull request the way GitHub exposes it on the base repository: `refs/pull/<number>/head` points at the
|
|
182
|
+
* head commit. With `fork`, the head is committed in a separate clone and pushed only to that ref, so no branch of the
|
|
183
|
+
* base repository contains it.
|
|
184
|
+
*/
|
|
185
|
+
export async function publishPullRequest(fixture, number, {fork = false, branch = 'feature', from, edit}) {
|
|
186
|
+
const dir = from ?? join(fixture.root, fork ? `fork-${number}` : `branch-${number}`);
|
|
187
|
+
if (!from) {
|
|
188
|
+
git(fixture.root, 'clone', '-q', fixture.bare, dir);
|
|
189
|
+
if (!fork) git(dir, 'switch', '-q', '-c', branch);
|
|
190
|
+
}
|
|
191
|
+
await edit(dir);
|
|
192
|
+
git(dir, 'add', '-A');
|
|
193
|
+
git(dir, '-c', 'user.name=Contributor', '-c', 'user.email=contributor@example.invalid', 'commit', '-qm', `pull request ${number}`);
|
|
194
|
+
const head = git(dir, 'rev-parse', 'HEAD');
|
|
195
|
+
if (!fork) git(dir, 'push', '-q', '--force', fixture.bare, branch);
|
|
196
|
+
git(dir, 'push', '-q', fixture.bare, `+HEAD:refs/pull/${number}/head`);
|
|
197
|
+
return {dir, head};
|
|
198
|
+
}
|