@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.8.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/LICENSE +373 -0
- package/README.ja.md +121 -0
- package/README.md +43 -2
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/THIRD_PARTY_NOTICES.md +1186 -0
- package/dist/block-diff-location.d.ts +22 -0
- package/dist/block-diff-preview.d.ts +33 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +18 -0
- package/dist/filesystem.d.ts +89 -0
- package/dist/github-pull-requests.d.ts +38 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +3563 -0
- package/dist/messages.d.ts +112 -0
- package/dist/mutation-fs.d.ts +58 -0
- package/dist/objectSpread2-Dkd68nJH.js +101 -0
- package/dist/project-asset-paths-KV715791.js +761 -0
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +26 -0
- package/dist/project-source-diff.js +172 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +23 -0
- package/dist/project-source-review.d.ts +38 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-BQkADJmK.js +17280 -0
- package/dist/repository.d.ts +71 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-xS8OYsRl.js +1830 -0
- package/dist/scratch-block-diff-renderer.d.ts +128 -0
- package/dist/smart-http-BmHEkE7K.js +303 -0
- package/dist/smart-http.d.ts +60 -0
- package/dist/smart-http.js +1 -0
- package/dist/style.css +138 -0
- package/package.json +65 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +117 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +44 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +229 -0
- package/src/index.tsx +352 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +108 -0
- package/src/project-source-diff.tsx +139 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +116 -0
- package/src/project-source-review.tsx +250 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +364 -0
- package/src/scratch-block-diff-renderer.ts +331 -0
- package/src/smart-http.ts +190 -0
- package/src/style.css +29 -0
- package/src/styles.d.ts +1 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubohiroya/turbowarp-plus-editor-git",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Read-only local Git panel for TurboWarp Plus Editor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MPL-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kubohiroya/turbowarp-plus-editor-git.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./repository": {
|
|
19
|
+
"types": "./dist/repository.d.ts",
|
|
20
|
+
"default": "./dist/repository.js"
|
|
21
|
+
},
|
|
22
|
+
"./style.css": "./dist/style.css",
|
|
23
|
+
"./smart-http": {
|
|
24
|
+
"types": "./dist/smart-http.d.ts",
|
|
25
|
+
"default": "./dist/smart-http.js"
|
|
26
|
+
},
|
|
27
|
+
"./project-source-diff": {
|
|
28
|
+
"types": "./dist/project-source-diff.d.ts",
|
|
29
|
+
"default": "./dist/project-source-diff.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"vite.config.ts",
|
|
36
|
+
"tsconfig.json",
|
|
37
|
+
"README.ja.md",
|
|
38
|
+
"THIRD_PARTY_NOTICES.md",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "vite build && tsc -p tsconfig.json",
|
|
47
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
48
|
+
"prepack": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": ">=16.14 <19",
|
|
52
|
+
"react-dom": ">=16.14 <19"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "24.19.2",
|
|
56
|
+
"@types/react": "16.14.70",
|
|
57
|
+
"@types/react-dom": "16.9.25",
|
|
58
|
+
"buffer": "6.0.3",
|
|
59
|
+
"diff": "9.0.0",
|
|
60
|
+
"isomorphic-git": "1.44.11",
|
|
61
|
+
"react": "16.14.0",
|
|
62
|
+
"react-diff-view": "3.3.3",
|
|
63
|
+
"react-dom": "16.14.0",
|
|
64
|
+
"typescript": "6.0.3",
|
|
65
|
+
"vite": "8.3.5"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {isRecord} from './project-source-patch.js';
|
|
2
|
+
import type {BlockPart, BlockSide} from './project-source-patch.js';
|
|
3
|
+
|
|
4
|
+
export interface DiffSvgBlock {
|
|
5
|
+
type?: string;
|
|
6
|
+
getSvgRoot(): Element | null;
|
|
7
|
+
getInputTargetBlock?(name: string): DiffSvgBlock | null;
|
|
8
|
+
getField?(name: string): {getSvgRoot(): Element | null} | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ProjectBlockLocation = BlockSide & {
|
|
12
|
+
version: 'before' | 'after';
|
|
13
|
+
parts: readonly BlockPart[];
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
};
|
|
16
|
+
export type ProjectBlockLocator = (location: ProjectBlockLocation) => boolean | Promise<boolean>;
|
|
17
|
+
|
|
18
|
+
export interface ScratchBlockLocationWorkspace {
|
|
19
|
+
getBlockById(id: string): DiffSvgBlock | null;
|
|
20
|
+
centerOnBlock(id: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function markBlockLocation(block: DiffSvgBlock, parts: readonly BlockPart[]) {
|
|
24
|
+
const root = block.getSvgRoot();
|
|
25
|
+
if (!root) return;
|
|
26
|
+
root.classList.add('twgit-changed-block');
|
|
27
|
+
const marked = new Set<Element>();
|
|
28
|
+
for (const {kind, name} of parts) {
|
|
29
|
+
const part = kind === 'input' ? block.getInputTargetBlock?.(name) : block.getField?.(name);
|
|
30
|
+
const element = part?.getSvgRoot();
|
|
31
|
+
if (!element || element === root || marked.has(element)) continue;
|
|
32
|
+
element.classList.add('twgit-changed-block-part');
|
|
33
|
+
marked.add(element);
|
|
34
|
+
}
|
|
35
|
+
return () => {
|
|
36
|
+
root.classList.remove('twgit-changed-block');
|
|
37
|
+
for (const element of marked) element.classList.remove('twgit-changed-block-part');
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve the matching project revision and target in the host before navigating its workspace. */
|
|
42
|
+
export function createScratchBlocksDiffLocator(
|
|
43
|
+
resolveWorkspace: (location: ProjectBlockLocation) => ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>
|
|
44
|
+
): ProjectBlockLocator {
|
|
45
|
+
return async location => {
|
|
46
|
+
const workspace = await resolveWorkspace(location);
|
|
47
|
+
if (!workspace || location.signal.aborted) return false;
|
|
48
|
+
const block = workspace.getBlockById(location.id);
|
|
49
|
+
const expected = location.target.blocks[location.id];
|
|
50
|
+
if (!block || (isRecord(expected) && typeof expected.opcode === 'string' && block.type !== expected.opcode)) return false;
|
|
51
|
+
const clear = markBlockLocation(block, location.parts);
|
|
52
|
+
if (!clear) return false;
|
|
53
|
+
try {
|
|
54
|
+
workspace.centerOnBlock(location.id);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
clear();
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (location.signal.aborted) {clear(); return false;}
|
|
60
|
+
location.signal.addEventListener('abort', clear, {once: true});
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
|
2
|
+
import type {BlockPart, BlockSide} from './project-source-patch.js';
|
|
3
|
+
import type {BlockPreviewView, ProjectBlockRenderer, ProjectBlockRendering, ScriptBlockMark} from './scratch-block-diff-renderer.js';
|
|
4
|
+
import {currentCostume, useCostumeImage} from './project-assets.js';
|
|
5
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
6
|
+
|
|
7
|
+
export type LocationStatus = 'locating' | 'located' | 'missingLocation';
|
|
8
|
+
type PreviewLabels = Record<'absent' | 'loading' | 'unavailable', string> & Partial<Record<LocationStatus | 'locate', string>>;
|
|
9
|
+
|
|
10
|
+
/** Keeps the previews of one comparison at the same zoom, height and scroll position while they are linked. */
|
|
11
|
+
export interface PreviewSync {
|
|
12
|
+
add(view: BlockPreviewView): () => void;
|
|
13
|
+
setLinked(linked: boolean): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPreviewSync(): PreviewSync {
|
|
17
|
+
const members = new Map<BlockPreviewView, () => void>();
|
|
18
|
+
let linked = true;
|
|
19
|
+
let moving = false;
|
|
20
|
+
// The version that needs the smaller zoom and the taller frame decides, so neither one is cut off.
|
|
21
|
+
const align = () => {
|
|
22
|
+
if (!linked || !members.size) return;
|
|
23
|
+
const views = [...members.keys()];
|
|
24
|
+
const fit = {scale: Math.min(...views.map(view => view.fit.scale)), height: Math.max(...views.map(view => view.fit.height))};
|
|
25
|
+
for (const view of views) view.layout(fit);
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
add(view) {
|
|
29
|
+
const stop = view.onChange(() => {
|
|
30
|
+
if (!linked || moving) return;
|
|
31
|
+
moving = true;
|
|
32
|
+
try {
|
|
33
|
+
const viewport = view.viewport();
|
|
34
|
+
for (const other of members.keys()) if (other !== view) other.setViewport(viewport);
|
|
35
|
+
} finally {
|
|
36
|
+
moving = false;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
members.set(view, stop);
|
|
40
|
+
align();
|
|
41
|
+
return () => {
|
|
42
|
+
stop();
|
|
43
|
+
members.delete(view);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
setLinked(value) {
|
|
47
|
+
if (linked === value) return;
|
|
48
|
+
linked = value;
|
|
49
|
+
align();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const linkLabels = {en: 'Move both versions together', ja: '変更前と変更後を連動して動かす'};
|
|
55
|
+
|
|
56
|
+
/** One sync group per comparison, with the checkbox that links or unlinks its previews. */
|
|
57
|
+
export function usePreviewSync(locale: string) {
|
|
58
|
+
const sync = useMemo(createPreviewSync, []);
|
|
59
|
+
const [linked, setLinked] = useState(true);
|
|
60
|
+
useEffect(() => sync.setLinked(linked), [sync, linked]);
|
|
61
|
+
const toggle = <label className="twgit-block-diff-link"><input type="checkbox" checked={linked}
|
|
62
|
+
onChange={event => setLinked(event.currentTarget.checked)}/> {locale.startsWith('ja') ? linkLabels.ja : linkLabels.en}</label>;
|
|
63
|
+
return {sync, toggle};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const rendering = (result: ProjectBlockRendering) =>
|
|
67
|
+
typeof result === 'function' ? {release: result} : {release: result && (() => result.dispose()), view: result?.view};
|
|
68
|
+
|
|
69
|
+
export function BlockPreview({side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus,
|
|
70
|
+
version, loadAsset, sync}: {
|
|
71
|
+
side?: BlockSide; title: string; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; renderBlock: ProjectBlockRenderer;
|
|
72
|
+
withContext: boolean; labels: PreviewLabels; onLocate?: () => void; locationStatus?: LocationStatus;
|
|
73
|
+
/** Which version this preview shows, for its costume thumbnail. */
|
|
74
|
+
version?: ProjectVersion; loadAsset?: ProjectAssetLoader; sync?: PreviewSync;
|
|
75
|
+
}) {
|
|
76
|
+
const host = useRef<HTMLDivElement>(null);
|
|
77
|
+
const watermark = useRef<HTMLDivElement>(null);
|
|
78
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>('loading');
|
|
79
|
+
const thumbnail = !!(side && version && loadAsset && currentCostume(side.target));
|
|
80
|
+
const image = useCostumeImage(thumbnail ? side!.target : undefined, version ?? 'after', loadAsset);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!side || !host.current) return;
|
|
83
|
+
const container = host.current;
|
|
84
|
+
const abort = new AbortController();
|
|
85
|
+
let dispose: (() => void) | undefined;
|
|
86
|
+
setStatus('loading');
|
|
87
|
+
void Promise.resolve().then(() => renderBlock({...side, container, withContext, parts, marks, signal: abort.signal,
|
|
88
|
+
watermark: thumbnail ? watermark.current ?? undefined : undefined})).then(result => {
|
|
89
|
+
const {release, view} = rendering(result);
|
|
90
|
+
if (abort.signal.aborted) {
|
|
91
|
+
release?.();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const leave = view && sync?.add(view);
|
|
95
|
+
dispose = () => {
|
|
96
|
+
leave?.();
|
|
97
|
+
release?.();
|
|
98
|
+
};
|
|
99
|
+
setStatus('ready');
|
|
100
|
+
}, () => {if (!abort.signal.aborted) setStatus('failed');});
|
|
101
|
+
return () => {abort.abort(); dispose?.();};
|
|
102
|
+
}, [side, renderBlock, withContext, parts, marks, sync, thumbnail]);
|
|
103
|
+
return <div className="twgit-block-diff-side">
|
|
104
|
+
<h4>{title}</h4>
|
|
105
|
+
{!side && <p>{labels.absent}</p>}
|
|
106
|
+
{side && status === 'loading' && <p role="status">{labels.loading}</p>}
|
|
107
|
+
{side && status === 'failed' && <p role="status">{labels.unavailable}</p>}
|
|
108
|
+
{side && <div ref={host} className="twgit-block-diff-preview">
|
|
109
|
+
{thumbnail && <div ref={watermark} className="twgit-block-diff-watermark" data-dockview-costume-thumbnail="">
|
|
110
|
+
{image && <img src={image} alt=""/>}
|
|
111
|
+
</div>}
|
|
112
|
+
</div>}
|
|
113
|
+
{side && onLocate && <button type="button" aria-label={`${title} · ${labels.locate}`}
|
|
114
|
+
onClick={onLocate}>{labels.locate}</button>}
|
|
115
|
+
{locationStatus && <p role="status">{labels[locationStatus]}</p>}
|
|
116
|
+
</div>;
|
|
117
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
3
|
+
|
|
4
|
+
type Mode = 'desktop' | 'cloudflare';
|
|
5
|
+
type Status = 'disconnected' | 'connecting' | 'connected' | 'reauth-required';
|
|
6
|
+
export interface GitConnectionSettings {
|
|
7
|
+
modes: readonly Mode[];
|
|
8
|
+
getView(): {
|
|
9
|
+
mode: Mode | null;
|
|
10
|
+
state: {status: Status; account?: {login: string}; expiresAt?: number};
|
|
11
|
+
proxyOrigin?: string;
|
|
12
|
+
write?: boolean;
|
|
13
|
+
authorization?: {url: string; userCode?: string};
|
|
14
|
+
};
|
|
15
|
+
select(mode: Mode): Promise<void>;
|
|
16
|
+
connect(remote: string, write?: boolean): Promise<void>;
|
|
17
|
+
refresh(): Promise<void>;
|
|
18
|
+
disconnect(): Promise<void>;
|
|
19
|
+
getSession?: GetRemoteSession;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const en = {
|
|
23
|
+
title: 'GitHub connection', mode: 'Connection mode', select: 'Choose a connection',
|
|
24
|
+
desktop: 'Desktop local', cloudflare: 'Cloudflare', proxy: 'Proxy', account: 'Account',
|
|
25
|
+
'disconnected': 'Disconnected', 'connecting': 'Waiting for GitHub', 'connected': 'Connected',
|
|
26
|
+
'reauth-required': 'Sign in again', connect: 'Connect to GitHub', open: 'Open GitHub authorization',
|
|
27
|
+
check: 'Check connection', disconnect: 'Disconnect', noRemote: 'Choose a repository with a GitHub HTTPS origin.',
|
|
28
|
+
unavailable: 'No GitHub connection is configured.', changed: 'Switching disconnects the current session.',
|
|
29
|
+
error: 'GitHub connection failed. Retry using the selected connection.',
|
|
30
|
+
invalid: 'The selected repository needs a GitHub HTTPS origin.',
|
|
31
|
+
allowPush: 'Authorize push', fetch: 'Fetch', pull: 'Pull (fast-forward only)', push: 'Push'
|
|
32
|
+
};
|
|
33
|
+
const ja: typeof en = {
|
|
34
|
+
title: 'GitHub 接続', mode: '接続方式', select: '接続方式を選択',
|
|
35
|
+
desktop: 'Desktop ローカル', cloudflare: 'Cloudflare', proxy: 'プロキシ', account: 'アカウント',
|
|
36
|
+
'disconnected': '未接続', 'connecting': 'GitHub の認証待ち', 'connected': '接続済み',
|
|
37
|
+
'reauth-required': '再認証が必要', connect: 'GitHub に接続', open: 'GitHub の認証を開く',
|
|
38
|
+
check: '接続を確認', disconnect: '切断', noRemote: 'GitHub の HTTPS リモート origin があるリポジトリを選択してください。',
|
|
39
|
+
unavailable: 'GitHub 接続先が設定されていません。', changed: '方式を切り替えると現在のセッションを切断します。',
|
|
40
|
+
error: 'GitHub 接続に失敗しました。選択中の方式で再試行してください。',
|
|
41
|
+
invalid: '選択したリポジトリには GitHub の HTTPS リモート origin が必要です。',
|
|
42
|
+
allowPush: 'push を許可する', fetch: 'Fetch', pull: 'Pull(fast-forward のみ)', push: 'Push'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function ConnectionSettings({settings, locale, readOrigin, runOperation}: {
|
|
46
|
+
settings: GitConnectionSettings; locale: string; readOrigin?: () => Promise<string | undefined>;
|
|
47
|
+
runOperation?: (operation: RemoteOperation) => Promise<void>;
|
|
48
|
+
}) {
|
|
49
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
50
|
+
const [, update] = useState(0);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
const [allowPush, setAllowPush] = useState(false);
|
|
53
|
+
const [error, setError] = useState<'error' | 'invalid' | null>(null);
|
|
54
|
+
const active = useRef(true);
|
|
55
|
+
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
|
|
56
|
+
const run = async (action: () => Promise<void>) => {
|
|
57
|
+
if (busy) return;
|
|
58
|
+
setBusy(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {await action();} catch (failure) {
|
|
61
|
+
if (active.current) setError((failure as Error).message === 'INVALID_REMOTE' ? 'invalid' : 'error');
|
|
62
|
+
} finally {
|
|
63
|
+
if (active.current) {setBusy(false); update(value => value + 1);}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const {mode, state, proxyOrigin, authorization, write} = settings.getView();
|
|
67
|
+
const connect = () => run(async () => {
|
|
68
|
+
const remote = await readOrigin?.();
|
|
69
|
+
if (!remote) throw new Error('INVALID_REMOTE');
|
|
70
|
+
await settings.connect(remote, allowPush);
|
|
71
|
+
});
|
|
72
|
+
return <details className="twgit-connection">
|
|
73
|
+
<summary>{labels.title}</summary>
|
|
74
|
+
{!settings.modes.length ? <p>{labels.unavailable}</p> : <>
|
|
75
|
+
<label>{labels.mode} <select value={mode ?? ''} disabled={busy}
|
|
76
|
+
onChange={event => {const choice = event.currentTarget.value as Mode; void run(() => settings.select(choice));}}>
|
|
77
|
+
<option value="" disabled>{labels.select}</option>
|
|
78
|
+
{settings.modes.map(choice => <option key={choice} value={choice}>{labels[choice]}</option>)}
|
|
79
|
+
</select></label>
|
|
80
|
+
<p>{labels.changed}</p>
|
|
81
|
+
<p data-git-connection-state="" role="status">{labels[state.status]}</p>
|
|
82
|
+
{proxyOrigin && <p>{labels.proxy} · <code>{proxyOrigin}</code></p>}
|
|
83
|
+
{state.account && <p>{labels.account} · {state.account.login}</p>}
|
|
84
|
+
{!readOrigin && <p>{labels.noRemote}</p>}
|
|
85
|
+
{mode && <>
|
|
86
|
+
{runOperation && <label><input type="checkbox" checked={allowPush}
|
|
87
|
+
disabled={busy || state.status === 'connecting' || state.status === 'connected'}
|
|
88
|
+
onChange={event => setAllowPush(event.currentTarget.checked)}/> {labels.allowPush}</label>}
|
|
89
|
+
<button disabled={busy || !readOrigin || state.status === 'connecting' || state.status === 'connected'}
|
|
90
|
+
onClick={connect}>{labels.connect}</button>
|
|
91
|
+
{authorization && <p>{authorization.userCode && <code>{authorization.userCode} </code>}
|
|
92
|
+
<a href={authorization.url} target="_blank" rel="noopener noreferrer">{labels.open}</a></p>}
|
|
93
|
+
{state.status !== 'disconnected' && <button disabled={busy}
|
|
94
|
+
onClick={() => void run(settings.refresh)}>{labels.check}</button>}
|
|
95
|
+
<button disabled={busy} onClick={() => void run(settings.disconnect)}>{labels.disconnect}</button>
|
|
96
|
+
{runOperation && state.status === 'connected' && (['fetch', 'pull', 'push'] as const).map(operation =>
|
|
97
|
+
<button key={operation} disabled={busy || !readOrigin || (operation === 'push' && !write)}
|
|
98
|
+
onClick={() => void run(() => runOperation(operation))}>{labels[operation]}</button>)}
|
|
99
|
+
</>}
|
|
100
|
+
{error && <p className="twgit-error" role="alert">{labels[error]}</p>}
|
|
101
|
+
</>}
|
|
102
|
+
</details>;
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import {Diff, Hunk, parseDiff} from 'react-diff-view';
|
|
3
|
+
import {ProjectSourceComparison} from './project-source-review.js';
|
|
4
|
+
import type {FileChange, FileDiff} from './repository.js';
|
|
5
|
+
import type {labelsFor} from './messages.js';
|
|
6
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
7
|
+
import type {ProjectAssetLoader} from './project-assets.js';
|
|
8
|
+
|
|
9
|
+
const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
|
|
10
|
+
const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
|
|
11
|
+
|
|
12
|
+
/** Project sources are the files the panel opens as projects. */
|
|
13
|
+
export const isProjectSource = (path: string) => path.split('/').pop() === 'project.source.json';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
|
|
17
|
+
*/
|
|
18
|
+
export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset}: {
|
|
19
|
+
change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
|
|
20
|
+
renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
21
|
+
}) {
|
|
22
|
+
const [view, setView] = useState<'blocks' | 'text'>('blocks');
|
|
23
|
+
const names = locale.startsWith('ja') ? ja : en;
|
|
24
|
+
const parsed = diff?.patch ? parseDiff(diff.patch)[0] : null;
|
|
25
|
+
const text = <>
|
|
26
|
+
<p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
|
|
27
|
+
{diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
|
|
28
|
+
{diff?.binary && <p>{labels.binary}</p>}
|
|
29
|
+
{diff?.tooLarge && <p>{labels.large}</p>}
|
|
30
|
+
{parsed && <Diff viewType="unified" diffType={parsed.type} hunks={parsed.hunks}>
|
|
31
|
+
{hunks => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk}/>)}</Diff>}
|
|
32
|
+
{diff && !diff.binary && !diff.tooLarge && !parsed?.hunks.length && <p>{labels.unchanged}</p>}
|
|
33
|
+
</>;
|
|
34
|
+
const sources = renderBlock && diff?.text && isProjectSource(change.path) ? diff.text : null;
|
|
35
|
+
return <>
|
|
36
|
+
<h3>{change.path}</h3>
|
|
37
|
+
{sources && <div className="twgit-view-switch" role="group" aria-label={names.view}>
|
|
38
|
+
{(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
|
|
39
|
+
data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
|
|
40
|
+
</div>}
|
|
41
|
+
{sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset}
|
|
42
|
+
before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
|
|
43
|
+
</>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export class GitPanelError extends Error {
|
|
2
|
+
constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
|
|
3
|
+
'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed', message: string = code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'GitPanelError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const fileError = (code: string, path: string) => Object.assign(new Error(`${code}: ${path}`), {code});
|
|
10
|
+
export const errorCode = (error: unknown) => (error as {code?: string})?.code;
|
|
11
|
+
|
|
12
|
+
export function pathParts(path: string): string[] {
|
|
13
|
+
const parts = path.split('/').filter(part => part && part !== '.');
|
|
14
|
+
if (parts.some(part => part === '..' || part.includes('\0'))) throw fileError('EACCES', path);
|
|
15
|
+
return parts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fileStat(file: {size: number; lastModified: number} | null) {
|
|
19
|
+
const time = file?.lastModified ?? 0;
|
|
20
|
+
return {
|
|
21
|
+
isFile: () => file !== null, isDirectory: () => file === null, isSymbolicLink: () => false,
|
|
22
|
+
size: file?.size ?? 0, mode: file ? 0o100644 : 0o40755,
|
|
23
|
+
mtimeMs: time, ctimeMs: time, atimeMs: time, birthtimeMs: time,
|
|
24
|
+
mtime: new Date(time), ctime: new Date(time), atime: new Date(time), birthtime: new Date(time),
|
|
25
|
+
dev: 0, ino: 0, uid: 0, gid: 0
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Adapt granted handles without exposing any filesystem mutation. */
|
|
30
|
+
export function createReadOnlyFs(root: FileSystemDirectoryHandle) {
|
|
31
|
+
const directory = async (parts: string[]) => {
|
|
32
|
+
let handle = root;
|
|
33
|
+
for (const name of parts) handle = await handle.getDirectoryHandle(name);
|
|
34
|
+
return handle;
|
|
35
|
+
};
|
|
36
|
+
const handle = async (path: string): Promise<FileSystemDirectoryHandle | FileSystemFileHandle> => {
|
|
37
|
+
const parts = pathParts(path);
|
|
38
|
+
if (!parts.length) return root;
|
|
39
|
+
const name = parts.pop()!;
|
|
40
|
+
try {
|
|
41
|
+
const parent = await directory(parts);
|
|
42
|
+
try {
|
|
43
|
+
return await parent.getFileHandle(name);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as DOMException).name !== 'TypeMismatchError') throw error;
|
|
46
|
+
return await parent.getDirectoryHandle(name);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as DOMException).name === 'NotFoundError') throw fileError('ENOENT', path);
|
|
50
|
+
if ((error as DOMException).name === 'TypeMismatchError') throw fileError('ENOTDIR', path);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const reads = new Map<string, {size: number; modified: number}>();
|
|
55
|
+
const listings = new Map<string, string[]>();
|
|
56
|
+
const observe = (path: string, file: File) => {
|
|
57
|
+
const previous = reads.get(path);
|
|
58
|
+
if (previous && (previous.size !== file.size || previous.modified !== file.lastModified)) {
|
|
59
|
+
throw new GitPanelError('changed');
|
|
60
|
+
}
|
|
61
|
+
reads.set(path, {size: file.size, modified: file.lastModified});
|
|
62
|
+
};
|
|
63
|
+
const readFile = async (path: string, options?: string | {encoding?: string}) => {
|
|
64
|
+
const item = await handle(path);
|
|
65
|
+
if (item.kind !== 'file') throw fileError('EISDIR', path);
|
|
66
|
+
const file = await item.getFile();
|
|
67
|
+
observe(path, file);
|
|
68
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
69
|
+
const encoding = typeof options === 'string' ? options : options?.encoding;
|
|
70
|
+
return encoding ? new TextDecoder(encoding).decode(bytes) : bytes;
|
|
71
|
+
};
|
|
72
|
+
const stat = async (path: string) => {
|
|
73
|
+
const item = await handle(path);
|
|
74
|
+
const file = item.kind === 'file' ? await item.getFile() : null;
|
|
75
|
+
if (file) observe(path, file);
|
|
76
|
+
return fileStat(file);
|
|
77
|
+
};
|
|
78
|
+
const rejectWrite = async (path: string): Promise<never> => {throw fileError('EROFS', path);};
|
|
79
|
+
return {
|
|
80
|
+
clearReads() {reads.clear(); listings.clear();},
|
|
81
|
+
async checkReads() {
|
|
82
|
+
for (const [path, expected] of reads) {
|
|
83
|
+
const item = await handle(path);
|
|
84
|
+
if (item.kind !== 'file') throw new GitPanelError('changed');
|
|
85
|
+
const file = await item.getFile();
|
|
86
|
+
if (file.size !== expected.size || file.lastModified !== expected.modified) throw new GitPanelError('changed');
|
|
87
|
+
}
|
|
88
|
+
for (const [path, expected] of listings) {
|
|
89
|
+
const item = await directory(pathParts(path));
|
|
90
|
+
const names: string[] = [];
|
|
91
|
+
for await (const name of (item as FileSystemDirectoryHandle & {keys(): AsyncIterable<string>}).keys()) names.push(name);
|
|
92
|
+
if (JSON.stringify(names.sort()) !== JSON.stringify(expected)) throw new GitPanelError('changed');
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
promises: {
|
|
96
|
+
readFile,
|
|
97
|
+
stat,
|
|
98
|
+
lstat: stat,
|
|
99
|
+
async readdir(path: string) {
|
|
100
|
+
const item = await handle(path);
|
|
101
|
+
if (item.kind !== 'directory') throw fileError('ENOTDIR', path);
|
|
102
|
+
const names: string[] = [];
|
|
103
|
+
for await (const name of (item as FileSystemDirectoryHandle & {keys(): AsyncIterable<string>}).keys()) {
|
|
104
|
+
names.push(name);
|
|
105
|
+
}
|
|
106
|
+
listings.set(path, names.sort());
|
|
107
|
+
return names;
|
|
108
|
+
},
|
|
109
|
+
writeFile: rejectWrite, unlink: rejectWrite, mkdir: rejectWrite, rmdir: rejectWrite,
|
|
110
|
+
symlink: rejectWrite, chmod: rejectWrite,
|
|
111
|
+
async readlink(): Promise<never> {throw new GitPanelError('unsupported');}
|
|
112
|
+
}};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readOptional(fs: ReturnType<typeof createReadOnlyFs>, path: string): Promise<Uint8Array | null> {
|
|
116
|
+
try {
|
|
117
|
+
return await fs.promises.readFile(path) as Uint8Array;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (errorCode(error) === 'ENOENT') return null;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const sameBytes = (left: Uint8Array | null, right: Uint8Array | null) =>
|
|
125
|
+
left === right || Boolean(left && right && left.length === right.length && left.every((byte, i) => byte === right[i]));
|