@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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {createProjectSourceDiff, isRecord} from './project-source-patch.js';
|
|
2
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
3
|
+
import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from './project-asset-paths.js';
|
|
4
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
5
|
+
|
|
6
|
+
type ErrorCode = 'unavailable' | 'rateLimit' | 'failed' | 'changed' | 'tooManyFiles';
|
|
7
|
+
export class PullRequestError extends Error {
|
|
8
|
+
constructor(readonly code: ErrorCode) {super(code);}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const repositoryName = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
12
|
+
const validRepository = (value: unknown): value is string => typeof value === 'string' &&
|
|
13
|
+
repositoryName.test(value) && value.split('/').every(segment => segment !== '.' && segment !== '..');
|
|
14
|
+
const isSha = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{40}$/i.test(value);
|
|
15
|
+
|
|
16
|
+
export function gitHubRepositoryFromOrigin(origin: string | undefined) {
|
|
17
|
+
const name = origin?.trim().match(/^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/?#]+\/[^/?#]+)\/?$/i)?.[1];
|
|
18
|
+
const repository = name?.replace(/\.git$/i, '');
|
|
19
|
+
return validRepository(repository) ? repository : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function repoPath(repository: string) {
|
|
23
|
+
return '/repos/' + repository.split('/').map(encodeURIComponent).join('/');
|
|
24
|
+
}
|
|
25
|
+
const invalidResponse = () => new PullRequestError('failed');
|
|
26
|
+
const emptyProject = '{"targets":[]}\n';
|
|
27
|
+
const sourceLimit = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
async function readBody(response: Response, limit: number, chunk: (value: Uint8Array) => void) {
|
|
30
|
+
if (Number(response.headers.get('Content-Length')) > limit || !response.body) throw invalidResponse();
|
|
31
|
+
const reader = response.body.getReader();
|
|
32
|
+
let length = 0;
|
|
33
|
+
try {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const {done, value} = await reader.read();
|
|
36
|
+
if (done) return;
|
|
37
|
+
length += value.byteLength;
|
|
38
|
+
if (length > limit) throw invalidResponse();
|
|
39
|
+
chunk(value);
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
void reader.cancel().catch(() => {});
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readText(response: Response, limit: number) {
|
|
48
|
+
const decoder = new TextDecoder('utf-8', {fatal: true});
|
|
49
|
+
const chunks: string[] = [];
|
|
50
|
+
await readBody(response, limit, value => chunks.push(decoder.decode(value, {stream: true})));
|
|
51
|
+
return chunks.join('') + decoder.decode();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readSummary(value: unknown) {
|
|
55
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.number) || Number(value.number) < 1 ||
|
|
56
|
+
typeof value.title !== 'string') throw invalidResponse();
|
|
57
|
+
return {number: Number(value.number), title: value.title};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRevision(value: unknown) {
|
|
61
|
+
if (!isRecord(value) || !isSha(value.sha) || typeof value.ref !== 'string') throw invalidResponse();
|
|
62
|
+
const repository = isRecord(value.repo) ? value.repo.full_name : null;
|
|
63
|
+
if (repository !== null && !validRepository(repository)) throw invalidResponse();
|
|
64
|
+
return {sha: value.sha, ref: value.ref, repository};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readPullRequest(value: unknown) {
|
|
68
|
+
const summary = readSummary(value);
|
|
69
|
+
if (!isRecord(value) || (value.state !== 'open' && value.state !== 'closed') ||
|
|
70
|
+
!Number.isSafeInteger(value.changed_files) || Number(value.changed_files) < 0) throw invalidResponse();
|
|
71
|
+
const author = isRecord(value.user) && typeof value.user.login === 'string' ? value.user.login : undefined;
|
|
72
|
+
const description = typeof value.body_text === 'string' ? value.body_text : typeof value.body === 'string' ? value.body : '';
|
|
73
|
+
const state: 'open' | 'closed' | 'merged' | 'draft' = value.merged_at ? 'merged' : value.draft ? 'draft' : value.state;
|
|
74
|
+
return {...summary, author, description, base: readRevision(value.base), head: readRevision(value.head), state,
|
|
75
|
+
changedFiles: Number(value.changed_files)};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readFile(value: unknown) {
|
|
79
|
+
if (!isRecord(value) || typeof value.filename !== 'string' || typeof value.status !== 'string') throw invalidResponse();
|
|
80
|
+
return {path: value.filename, previous: typeof value.previous_filename === 'string' ? value.previous_filename : undefined,
|
|
81
|
+
status: value.status};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function contentPath(path: string) {
|
|
85
|
+
const parts = path.split('/');
|
|
86
|
+
if (parts.some(part => !part || part === '.' || part === '..')) throw invalidResponse();
|
|
87
|
+
return parts.map(encodeURIComponent).join('/');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One version of a project file on GitHub: the repository and commit, and the project file's path. */
|
|
91
|
+
interface ProjectLocation {repository: string; sha: string; path: string}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads the assets of both versions of a project file from raw.githubusercontent.com, which does not count against the
|
|
95
|
+
* API rate limit. Each version's sb3-source.json names its assets directory. Every read is capped, honors both its own
|
|
96
|
+
* signal and the pull request's, and resolves `null` on any failure.
|
|
97
|
+
*/
|
|
98
|
+
function rawAssetLoader(versions: Record<ProjectVersion, ProjectLocation | null>, send: typeof fetch, review: AbortSignal): ProjectAssetLoader {
|
|
99
|
+
const directories = new Map<ProjectVersion, Promise<string[] | null>>();
|
|
100
|
+
const get = (url: string, signal: AbortSignal) => send(url, {credentials: 'omit', redirect: 'error', signal});
|
|
101
|
+
const folderUrl = ({repository, sha, path}: ProjectLocation) => 'https://raw.githubusercontent.com/' +
|
|
102
|
+
repository.split('/').map(encodeURIComponent).join('/') + '/' + sha + '/' +
|
|
103
|
+
contentPath(path).split('/').slice(0, -1).map(part => part + '/').join('');
|
|
104
|
+
const readDirectory = async (folder: string, signal: AbortSignal) => {
|
|
105
|
+
let settings: string | null = null;
|
|
106
|
+
try {
|
|
107
|
+
const response = await get(folder + 'sb3-source.json', signal);
|
|
108
|
+
if (response.ok) settings = await readText(response, folderSettingsLimit);
|
|
109
|
+
else void response.body?.cancel().catch(() => {});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// A missing or unreadable file falls back to `assets`; a cancelled read is retried by the next request.
|
|
112
|
+
if (signal.aborted) throw error;
|
|
113
|
+
}
|
|
114
|
+
return assetsDirectoryParts(settings);
|
|
115
|
+
};
|
|
116
|
+
return async ({version, md5ext, signal: own}) => {
|
|
117
|
+
const location = versions[version];
|
|
118
|
+
if (!location || !isAssetName(md5ext)) return null;
|
|
119
|
+
try {
|
|
120
|
+
const signal = AbortSignal.any([own, review]);
|
|
121
|
+
if (signal.aborted) return null;
|
|
122
|
+
const folder = folderUrl(location);
|
|
123
|
+
let directory = directories.get(version);
|
|
124
|
+
if (!directory) {
|
|
125
|
+
directory = readDirectory(folder, signal);
|
|
126
|
+
directories.set(version, directory);
|
|
127
|
+
const pending = directory;
|
|
128
|
+
pending.catch(() => {if (directories.get(version) === pending) directories.delete(version);});
|
|
129
|
+
}
|
|
130
|
+
const parts = await directory;
|
|
131
|
+
if (!parts || signal.aborted) return null;
|
|
132
|
+
const response = await get(folder + parts.map(encodeURIComponent).join('/') + '/' + md5ext, signal);
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
void response.body?.cancel().catch(() => {});
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
138
|
+
await readBody(response, assetLimit, value => chunks.push(value as Uint8Array<ArrayBuffer>));
|
|
139
|
+
return signal.aborted ? null : new Blob(chunks);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createGitHubPullRequests(repository: string, send: typeof fetch = fetch) {
|
|
147
|
+
if (!validRepository(repository)) throw invalidResponse();
|
|
148
|
+
const api = 'https://api.github.com';
|
|
149
|
+
const repo = repoPath(repository);
|
|
150
|
+
const request = async (path: string, signal: AbortSignal, accept = 'application/vnd.github+json') => {
|
|
151
|
+
const response = await send(api + path, {headers: {Accept: accept}, credentials: 'omit', redirect: 'error', signal});
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
if (response.status === 429 || (response.status === 403 && (response.headers.get('X-RateLimit-Remaining') === '0' ||
|
|
154
|
+
response.headers.has('Retry-After')))) throw new PullRequestError('rateLimit');
|
|
155
|
+
if (response.status === 404 || response.status === 403) throw new PullRequestError('unavailable');
|
|
156
|
+
throw invalidResponse();
|
|
157
|
+
}
|
|
158
|
+
return response;
|
|
159
|
+
};
|
|
160
|
+
const json = async (path: string, signal: AbortSignal, accept?: string): Promise<unknown> =>
|
|
161
|
+
JSON.parse(await readText(await request(path, signal, accept), 12 * sourceLimit));
|
|
162
|
+
const metadata = async (number: number, signal: AbortSignal) => readPullRequest(await json(
|
|
163
|
+
repo + '/pulls/' + number, signal, 'application/vnd.github.text+json'));
|
|
164
|
+
const source = async (name: string, path: string, revision: string, signal: AbortSignal) => readText(
|
|
165
|
+
await request(repoPath(name) + '/contents/' + contentPath(path) + '?ref=' + revision, signal, 'application/vnd.github.raw+json'), sourceLimit);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
async list(page: number, signal: AbortSignal) {
|
|
169
|
+
const result = await json(repo + '/pulls?state=all&sort=updated&per_page=20&page=' + page, signal);
|
|
170
|
+
if (!Array.isArray(result)) throw invalidResponse();
|
|
171
|
+
return {items: result.map(readSummary), more: result.length === 20};
|
|
172
|
+
},
|
|
173
|
+
async load(number: number, signal: AbortSignal) {
|
|
174
|
+
if (!Number.isSafeInteger(number) || number < 1) throw invalidResponse();
|
|
175
|
+
const pr = await metadata(number, signal);
|
|
176
|
+
if (pr.number !== number) throw invalidResponse();
|
|
177
|
+
if (pr.changedFiles > 3000) throw new PullRequestError('tooManyFiles');
|
|
178
|
+
const records: ReturnType<typeof readFile>[] = [];
|
|
179
|
+
for (let page = 1; page <= Math.ceil(pr.changedFiles / 100); page++) {
|
|
180
|
+
const result = await json(repo + '/pulls/' + number + '/files?per_page=100&page=' + page, signal);
|
|
181
|
+
if (!Array.isArray(result)) throw invalidResponse();
|
|
182
|
+
records.push(...result.map(readFile));
|
|
183
|
+
}
|
|
184
|
+
const current = await metadata(number, signal);
|
|
185
|
+
if (current.base.sha !== pr.base.sha || current.head.sha !== pr.head.sha ||
|
|
186
|
+
current.changedFiles !== pr.changedFiles || records.length !== pr.changedFiles) {
|
|
187
|
+
throw new PullRequestError('changed');
|
|
188
|
+
}
|
|
189
|
+
let base: string | undefined;
|
|
190
|
+
if (records.some(file => file.path.split('/').pop() === 'project.source.json' && file.status !== 'added' && file.status !== 'copied')) {
|
|
191
|
+
try {
|
|
192
|
+
const comparison = await json(repo + '/compare/' + pr.base.sha + '...' + pr.head.sha + '?per_page=1', signal);
|
|
193
|
+
const merge = isRecord(comparison) ? comparison.merge_base_commit : null;
|
|
194
|
+
if (isRecord(merge) && isSha(merge.sha)) base = merge.sha;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (signal.aborted) throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const files: ProjectSourceReviewFile[] = [];
|
|
201
|
+
for (const file of records) {
|
|
202
|
+
const status = file.status === 'removed' ? 'removed' : file.status === 'added' || file.status === 'copied' ? 'added' : 'updated';
|
|
203
|
+
const related = {path: file.path, status, previousPath: file.previous} as const;
|
|
204
|
+
if (file.path.split('/').pop() !== 'project.source.json') {
|
|
205
|
+
files.push(related);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (status !== 'added' && !base) throw invalidResponse();
|
|
210
|
+
const [before, after] = await Promise.all([
|
|
211
|
+
status === 'added' ? emptyProject : source(repository, file.previous ?? file.path, base!, signal),
|
|
212
|
+
status === 'removed' ? emptyProject : source(pr.head.repository ?? repository, file.path, pr.head.sha, signal)
|
|
213
|
+
]);
|
|
214
|
+
const loadAsset = rawAssetLoader({
|
|
215
|
+
before: status === 'added' ? null : {repository, sha: base!, path: file.previous ?? file.path},
|
|
216
|
+
after: status === 'removed' ? null : {repository: pr.head.repository ?? repository, sha: pr.head.sha, path: file.path}
|
|
217
|
+
}, send, signal);
|
|
218
|
+
files.push({path: file.path, previousPath: file.previous, patch: createProjectSourceDiff(before, after), loadAsset});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (signal.aborted) throw error;
|
|
221
|
+
files.push({...related, unavailable: true});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {...pr, files, url: 'https://github.com/' + repository + '/pull/' + number};
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type GitHubPullRequests = ReturnType<typeof createGitHubPullRequests>;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
|
2
|
+
import {render, unmountComponentAtNode} from 'react-dom';
|
|
3
|
+
import {FileDiffView} from './file-diff-view.js';
|
|
4
|
+
import {openRepository} from './repository.js';
|
|
5
|
+
import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup} from './repository.js';
|
|
6
|
+
import {labelsFor} from './messages.js';
|
|
7
|
+
import {GitPanelError} from './filesystem.js';
|
|
8
|
+
import {ConnectionSettings} from './connection-settings.js';
|
|
9
|
+
import type {GitConnectionSettings} from './connection-settings.js';
|
|
10
|
+
import type {RemoteOperation} from './remote-operations.js';
|
|
11
|
+
import {PullRequestReviewPanel} from './pull-request-panel.js';
|
|
12
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
13
|
+
|
|
14
|
+
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
15
|
+
export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
16
|
+
|
|
17
|
+
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
18
|
+
export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
|
|
19
|
+
|
|
20
|
+
export interface GitHost {
|
|
21
|
+
connectionSettings?: GitConnectionSettings;
|
|
22
|
+
renderReviewBlock?: ProjectBlockRenderer;
|
|
23
|
+
openProject(directory: FileSystemDirectoryHandle, confirmReplace: () => boolean): Promise<'opened' | 'cancelled'>;
|
|
24
|
+
isProjectDirty(): boolean;
|
|
25
|
+
isProjectRunning?(): boolean;
|
|
26
|
+
subscribe?(listener: (event: 'loaded' | 'changed') => void): () => void;
|
|
27
|
+
}
|
|
28
|
+
interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
|
|
29
|
+
interface State {
|
|
30
|
+
repository: Repository | null;
|
|
31
|
+
snapshot: Snapshot | null;
|
|
32
|
+
project: string | null;
|
|
33
|
+
browser: {path: string; children: string[]; isProject: boolean} | null;
|
|
34
|
+
changes: FileChange[];
|
|
35
|
+
selected: FileChange | null;
|
|
36
|
+
diff: FileDiff | null;
|
|
37
|
+
commit: string | null;
|
|
38
|
+
error: string | null;
|
|
39
|
+
notice?: string;
|
|
40
|
+
busy: boolean;
|
|
41
|
+
}
|
|
42
|
+
const initial: State = {repository: null, snapshot: null, project: null, browser: null,
|
|
43
|
+
changes: [], selected: null, diff: null, commit: null, error: null, busy: false};
|
|
44
|
+
const controllers = new WeakMap<object, Controller>();
|
|
45
|
+
type PickerWindow = Window & {showDirectoryPicker?: (options: {id: string; mode: 'readwrite'}) => Promise<FileSystemDirectoryHandle>};
|
|
46
|
+
type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void) => {
|
|
47
|
+
observe(handle: FileSystemDirectoryHandle, options: {recursive: boolean}): Promise<void>;
|
|
48
|
+
disconnect(): void;
|
|
49
|
+
}};
|
|
50
|
+
const watchInterval = 2000;
|
|
51
|
+
|
|
52
|
+
function GitPanel({document, host, controller}: {document: Document; host: GitHost; controller: Controller}) {
|
|
53
|
+
const [visible, setVisible] = useState(false);
|
|
54
|
+
const [review, setReview] = useState(false);
|
|
55
|
+
const [locale, setLocale] = useState(document.documentElement.lang || 'en');
|
|
56
|
+
const [dirty, setDirty] = useState(host.isProjectDirty());
|
|
57
|
+
const [state, setState] = useState<State>(initial);
|
|
58
|
+
const current = useRef(state);
|
|
59
|
+
current.current = state;
|
|
60
|
+
const epoch = useRef(0);
|
|
61
|
+
const running = useRef(false);
|
|
62
|
+
const refreshPending = useRef(false);
|
|
63
|
+
const remoteRequest = useRef<AbortController>();
|
|
64
|
+
const alive = useRef(true);
|
|
65
|
+
const opening = useRef(false);
|
|
66
|
+
const dialog = useRef<HTMLDivElement>(null);
|
|
67
|
+
const reviewing = useRef(review);
|
|
68
|
+
reviewing.current = review;
|
|
69
|
+
// The last fingerprint acted on, so a failing read or an open diff is not retried on every check.
|
|
70
|
+
const handled = useRef<string | null>(null);
|
|
71
|
+
const labels = labelsFor(locale);
|
|
72
|
+
// A background refresh never drops a user's operation: the operation waits for that refresh instead.
|
|
73
|
+
const background = useRef<Promise<void> | null>(null);
|
|
74
|
+
const run = async (operation: () => Promise<Partial<State>>, automatic = false) => {
|
|
75
|
+
if (running.current && !automatic && background.current) await background.current;
|
|
76
|
+
if (running.current || !alive.current) return;
|
|
77
|
+
let settle = () => {};
|
|
78
|
+
if (automatic) background.current = new Promise(resolve => {settle = resolve;});
|
|
79
|
+
running.current = true;
|
|
80
|
+
const ticket = ++epoch.current;
|
|
81
|
+
// An automatic refresh keeps the current error and notice, such as an uncertain push result.
|
|
82
|
+
setState(previous => automatic ? {...previous, busy: true} : {...previous, busy: true, error: null, notice: undefined});
|
|
83
|
+
try {
|
|
84
|
+
const update = await operation();
|
|
85
|
+
if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: false}));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (epoch.current !== ticket) return;
|
|
88
|
+
const code = error instanceof GitPanelError ? error.code : 'failed';
|
|
89
|
+
const message = labels[code as keyof typeof labels] || labels.failed;
|
|
90
|
+
setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
|
|
91
|
+
} finally {
|
|
92
|
+
running.current = false;
|
|
93
|
+
if (automatic) {background.current = null; settle();}
|
|
94
|
+
if (refreshPending.current) {
|
|
95
|
+
refreshPending.current = false;
|
|
96
|
+
queueMicrotask(() => void controller.refresh());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
controller.open = language => {setLocale(language); setVisible(true);};
|
|
101
|
+
controller.refresh = async (automatic = false) => {
|
|
102
|
+
if (!alive.current || !current.current.repository) return;
|
|
103
|
+
if (running.current) {
|
|
104
|
+
if (!automatic) refreshPending.current = true;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await run(async () => {
|
|
108
|
+
const {repository, project} = current.current;
|
|
109
|
+
const snapshot = await repository!.read(project ?? '');
|
|
110
|
+
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
|
|
111
|
+
}, automatic);
|
|
112
|
+
};
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
alive.current = true;
|
|
115
|
+
const unsubscribe = host.subscribe?.(event => {
|
|
116
|
+
setDirty(host.isProjectDirty());
|
|
117
|
+
if (event === 'loaded' && !opening.current) {
|
|
118
|
+
epoch.current++;
|
|
119
|
+
setState(previous => ({...previous, project: null, snapshot: null, changes: [],
|
|
120
|
+
selected: null, diff: null, commit: null, busy: false}));
|
|
121
|
+
queueMicrotask(() => void controller.refresh());
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return () => {alive.current = false; epoch.current++; remoteRequest.current?.abort(); unsubscribe?.();};
|
|
125
|
+
}, [host]);
|
|
126
|
+
// While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const repository = state.repository;
|
|
129
|
+
if (!visible || !repository) return;
|
|
130
|
+
const view = document.defaultView as ObserverWindow;
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let checking = false;
|
|
133
|
+
const check = async () => {
|
|
134
|
+
const {snapshot} = current.current;
|
|
135
|
+
if (stopped || checking || running.current || document.visibilityState === 'hidden' ||
|
|
136
|
+
!snapshot || current.current.repository !== repository) return;
|
|
137
|
+
checking = true;
|
|
138
|
+
try {
|
|
139
|
+
const probe = await repository.probe(snapshot.scope);
|
|
140
|
+
if (stopped || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
|
|
141
|
+
current.current.snapshot !== snapshot) return;
|
|
142
|
+
handled.current = probe;
|
|
143
|
+
const {selected, commit} = current.current;
|
|
144
|
+
// Keep an open diff, commit or review in place and ask for a refresh instead.
|
|
145
|
+
if (selected || commit || reviewing.current) {
|
|
146
|
+
setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
|
|
147
|
+
} else await controller.refresh(true);
|
|
148
|
+
} catch {
|
|
149
|
+
// Entries can disappear while another tool writes. The next check reads them again.
|
|
150
|
+
} finally {
|
|
151
|
+
checking = false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
let soon: number | undefined;
|
|
155
|
+
const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void check(), 300);};
|
|
156
|
+
const interval = view.setInterval(() => void check(), watchInterval);
|
|
157
|
+
const onFocus = () => void check();
|
|
158
|
+
view.addEventListener('focus', onFocus);
|
|
159
|
+
document.addEventListener('visibilitychange', onFocus);
|
|
160
|
+
let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
|
|
161
|
+
try {
|
|
162
|
+
if (view.FileSystemObserver) {
|
|
163
|
+
observer = new view.FileSystemObserver(schedule);
|
|
164
|
+
observer.observe(repository.root, {recursive: true}).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
observer = null;
|
|
168
|
+
}
|
|
169
|
+
void check();
|
|
170
|
+
return () => {
|
|
171
|
+
stopped = true;
|
|
172
|
+
view.clearInterval(interval);
|
|
173
|
+
view.clearTimeout(soon);
|
|
174
|
+
view.removeEventListener('focus', onFocus);
|
|
175
|
+
document.removeEventListener('visibilitychange', onFocus);
|
|
176
|
+
observer?.disconnect();
|
|
177
|
+
};
|
|
178
|
+
}, [visible, state.repository, locale]);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!visible) return;
|
|
181
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
182
|
+
dialog.current?.focus();
|
|
183
|
+
return () => {if (previous?.isConnected) previous.focus();};
|
|
184
|
+
}, [visible]);
|
|
185
|
+
const chooseRoot = async () => {
|
|
186
|
+
const view = document.defaultView as PickerWindow;
|
|
187
|
+
await run(async () => {
|
|
188
|
+
let root: FileSystemDirectoryHandle;
|
|
189
|
+
try {root = await view.showDirectoryPicker!({id: 'turbowarp-plus-git-root', mode: 'readwrite'});} catch (error) {
|
|
190
|
+
if ((error as DOMException).name === 'AbortError') return {};
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
const repository = await openRepository(root);
|
|
194
|
+
const snapshot = await repository.read();
|
|
195
|
+
const browser = await repository.browse();
|
|
196
|
+
await host.connectionSettings?.disconnect();
|
|
197
|
+
setReview(false);
|
|
198
|
+
return {...initial, repository, snapshot, browser, changes: snapshot.changes};
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
const browse = (path: string) => run(async () => ({browser: await state.repository!.browse(path)}));
|
|
202
|
+
const openProject = (path: string) => run(async () => {
|
|
203
|
+
const directory = await state.repository!.openProject(path);
|
|
204
|
+
opening.current = true;
|
|
205
|
+
try {
|
|
206
|
+
const result = await host.openProject(directory, () => document.defaultView!.confirm(labels.replace));
|
|
207
|
+
if (result === 'cancelled') return {};
|
|
208
|
+
} finally {opening.current = false;}
|
|
209
|
+
if (!alive.current) return {};
|
|
210
|
+
setDirty(host.isProjectDirty());
|
|
211
|
+
setState(previous => ({...previous, project: path, snapshot: null, changes: [], selected: null, diff: null, commit: null}));
|
|
212
|
+
const snapshot = await state.repository!.read(path);
|
|
213
|
+
return {project: path, snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
|
|
214
|
+
});
|
|
215
|
+
const selectFile = (selected: FileChange) => run(async () => ({selected,
|
|
216
|
+
diff: await state.repository!.readDiff(state.snapshot!, selected)}));
|
|
217
|
+
const selectCommit = (commit: string) => run(async () => ({commit,
|
|
218
|
+
changes: await state.repository!.historyChanges(state.snapshot!, commit), selected: null, diff: null}));
|
|
219
|
+
const remoteOperation = async (operation: RemoteOperation) => {
|
|
220
|
+
const repository = current.current.repository;
|
|
221
|
+
const getSession = host.connectionSettings?.getSession;
|
|
222
|
+
if (!repository || !getSession) return;
|
|
223
|
+
if (operation === 'push' && !document.defaultView?.confirm(labels.confirmPush)) return;
|
|
224
|
+
await run(async () => {
|
|
225
|
+
const ticket = epoch.current;
|
|
226
|
+
const request = new AbortController();
|
|
227
|
+
remoteRequest.current = request;
|
|
228
|
+
try {
|
|
229
|
+
await repository.remoteOperation(operation, getSession, () => {
|
|
230
|
+
if (epoch.current !== ticket) throw new GitPanelError('changed');
|
|
231
|
+
if (operation === 'pull' && (host.isProjectDirty() || host.isProjectRunning?.()))
|
|
232
|
+
throw new GitPanelError('pullUnsafe');
|
|
233
|
+
}, request.signal);
|
|
234
|
+
} finally {
|
|
235
|
+
remoteRequest.current = undefined;
|
|
236
|
+
}
|
|
237
|
+
const snapshot = await repository.read(current.current.project ?? '');
|
|
238
|
+
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null,
|
|
239
|
+
notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
243
|
+
if (event.key === 'Escape') {event.stopPropagation(); setVisible(false); return;}
|
|
244
|
+
if (event.key !== 'Tab') return;
|
|
245
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
|
|
246
|
+
.filter(item => item.getClientRects().length > 0);
|
|
247
|
+
const first = items[0], last = items[items.length - 1];
|
|
248
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) {
|
|
249
|
+
event.preventDefault(); last?.focus();
|
|
250
|
+
} else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first?.focus();}
|
|
251
|
+
};
|
|
252
|
+
// One loader per selected change, so thumbnails read each asset once.
|
|
253
|
+
const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
|
|
254
|
+
state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
|
|
255
|
+
if (!visible) return null;
|
|
256
|
+
const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
|
|
257
|
+
return <div className="twgit-backdrop">
|
|
258
|
+
<div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
|
|
259
|
+
onKeyDown={focusTrap}>
|
|
260
|
+
<header>
|
|
261
|
+
<h2>{labels.title}</h2>
|
|
262
|
+
<button onClick={chooseRoot} disabled={state.busy}>{labels.choose}</button>
|
|
263
|
+
{!review && <button onClick={() => void controller.refresh()} disabled={!state.repository || state.busy}>{labels.refresh}</button>}
|
|
264
|
+
{!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
|
|
265
|
+
{state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
|
|
266
|
+
onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
|
|
267
|
+
<button onClick={() => setVisible(false)}>{labels.close}</button>
|
|
268
|
+
</header>
|
|
269
|
+
<div className="twgit-context">
|
|
270
|
+
<span>{labels.repository} · {state.repository?.root.name || '—'}</span>
|
|
271
|
+
<span>{labels.project} · {state.project === null ? labels.none : state.project || labels.root}</span>
|
|
272
|
+
<strong>{state.snapshot?.branch || (state.snapshot?.head ? labels.detached : labels.unborn)}</strong>
|
|
273
|
+
<code>{state.snapshot?.head?.slice(0, 12)}</code>
|
|
274
|
+
<span data-git-dirty="">{dirty ? labels.dirty : labels.saved}</span>
|
|
275
|
+
</div>
|
|
276
|
+
{!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
|
|
277
|
+
runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
|
|
278
|
+
readOrigin={state.repository && !state.busy ? async () => {
|
|
279
|
+
const ticket = epoch.current;
|
|
280
|
+
const remote = await state.repository!.readOrigin();
|
|
281
|
+
return epoch.current === ticket ? remote : undefined;
|
|
282
|
+
} : undefined}/>}
|
|
283
|
+
{state.error && <p className="twgit-error" role="alert">{state.error}</p>}
|
|
284
|
+
{state.notice && <p className="twgit-context" role="status">{state.notice}</p>}
|
|
285
|
+
{state.busy && <p role="status">{labels.loading}</p>}
|
|
286
|
+
{!state.repository && <p>{labels.empty}</p>}
|
|
287
|
+
{review && state.repository && host.renderReviewBlock &&
|
|
288
|
+
<PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}/>}
|
|
289
|
+
{!review && state.repository && <div className="twgit-content">
|
|
290
|
+
<aside>
|
|
291
|
+
{state.browser && <details>
|
|
292
|
+
<summary>{labels.browse}</summary>
|
|
293
|
+
<p><code>{state.browser.path || '/'}</code></p>
|
|
294
|
+
<button disabled={state.busy || !state.browser.path} onClick={() => void browse(state.browser!.path.split('/').slice(0, -1).join('/'))}>{labels.up}</button>
|
|
295
|
+
<button data-git-open-project="" disabled={state.busy || !state.browser.isProject} onClick={() => void openProject(state.browser!.path)}>{labels.open}</button>
|
|
296
|
+
<ul>{state.browser.children.map(name => <li key={name}><button disabled={state.busy}
|
|
297
|
+
onClick={() => void browse([state.browser!.path, name].filter(Boolean).join('/'))}>{name}/</button></li>)}</ul>
|
|
298
|
+
</details>}
|
|
299
|
+
<h3><button disabled={state.busy} onClick={() => void controller.refresh()}>{labels.worktree}</button></h3>
|
|
300
|
+
{groups.map(group => <section key={group}>
|
|
301
|
+
<h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
|
|
302
|
+
<ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
|
|
303
|
+
<button className="twgit-file" data-git-group={group} disabled={state.busy}
|
|
304
|
+
aria-pressed={state.selected === change} onClick={() => void selectFile(change)}>
|
|
305
|
+
<span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
|
|
306
|
+
</button>
|
|
307
|
+
</li>)}</ul>
|
|
308
|
+
</section>)}
|
|
309
|
+
{!state.changes.length && <p>{labels.clean}</p>}
|
|
310
|
+
<details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
|
|
311
|
+
<h3>{labels.history}</h3>
|
|
312
|
+
<ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
|
|
313
|
+
<button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
|
|
314
|
+
onClick={() => void selectCommit(commit.oid)}>{commit.message.split('\n')[0]}</button>
|
|
315
|
+
<small><code>{commit.oid.slice(0, 8)}</code> · {commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
|
|
316
|
+
</li>)}</ol>
|
|
317
|
+
</aside>
|
|
318
|
+
<main className="twgit-diff">
|
|
319
|
+
{state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
|
|
320
|
+
renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
|
|
321
|
+
</main>
|
|
322
|
+
</div>}
|
|
323
|
+
<footer>{labels.permissions}</footer>
|
|
324
|
+
</div>
|
|
325
|
+
</div>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
|
|
329
|
+
if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
|
|
330
|
+
const root = document.createElement('div');
|
|
331
|
+
document.body.append(root);
|
|
332
|
+
const controller: Controller = {open() {}, async refresh() {}};
|
|
333
|
+
controllers.set(vm, controller);
|
|
334
|
+
render(<GitPanel document={document} host={host} controller={controller}/>, root);
|
|
335
|
+
return {refresh: () => controller.refresh(), dispose() {
|
|
336
|
+
if (controllers.get(vm) === controller) controllers.delete(vm);
|
|
337
|
+
void host.connectionSettings?.disconnect().catch(() => {});
|
|
338
|
+
unmountComponentAtNode(root);
|
|
339
|
+
root.remove();
|
|
340
|
+
}};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function renderGitMenu(createElement: (component: any, props: any, ...children: any[]) => any,
|
|
344
|
+
{vm, locale = 'en', MenuItem, MenuSection, onRequestClose}: {
|
|
345
|
+
vm: object; locale?: string; MenuItem: any; MenuSection: any; onRequestClose(): void;
|
|
346
|
+
}) {
|
|
347
|
+
const controller = controllers.get(vm);
|
|
348
|
+
if (!controller) return null;
|
|
349
|
+
return createElement(MenuSection, null, createElement(MenuItem, {
|
|
350
|
+
command: 'local-git', onClick() {onRequestClose(); controller.open(locale);}
|
|
351
|
+
}, labelsFor(locale).menu));
|
|
352
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const messages = {
|
|
2
|
+
en: {
|
|
3
|
+
pullRequests: 'Pull requests', localChanges: 'Local changes',
|
|
4
|
+
title: 'Local Git', menu: 'Local Git…', close: 'Close', choose: 'Choose Repository…', refresh: 'Refresh',
|
|
5
|
+
empty: 'Choose an existing Git repository to inspect its files and history.',
|
|
6
|
+
repository: 'Repository', project: 'Project folder', none: 'No project selected', root: 'Repository root',
|
|
7
|
+
open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
|
|
8
|
+
replace: 'Replace the open project and discard its unsaved changes?',
|
|
9
|
+
dirty: 'The Editor has unsaved changes.', saved: 'The Editor has no unsaved changes.',
|
|
10
|
+
staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit changes',
|
|
11
|
+
history: 'History', worktree: 'Working tree', clean: 'No changes in the selected folder.',
|
|
12
|
+
branches: 'Local branches', unborn: 'No commits yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
|
|
13
|
+
binary: 'Binary asset', large: 'Text preview exceeds its display limit.', bytes: 'bytes',
|
|
14
|
+
mode: 'Git file mode', permissions: 'Working-tree executable permission changes are unavailable in the browser.',
|
|
15
|
+
unchanged: 'The text is unchanged.', loading: 'Reading repository…',
|
|
16
|
+
invalid: 'Choose a repository with a .git directory, and a project inside that repository.',
|
|
17
|
+
changed: 'Files or Git state changed while reading. Refresh to read the current state.',
|
|
18
|
+
outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
|
|
19
|
+
unsupported: 'This repository contains an entry the browser cannot represent.',
|
|
20
|
+
pullUnsafe: 'Stop the project and clear unsaved or local Git changes before pulling.',
|
|
21
|
+
diverged: 'Pull requires a fast-forward. Your local branch and files are intact.',
|
|
22
|
+
session: 'Reconnect GitHub for this repository and requested permission.',
|
|
23
|
+
pushUnknown: 'The push result is unknown. Fetch and check the remote branch before retrying.',
|
|
24
|
+
pushRefresh: 'The push succeeded. Fetch to refresh the local tracking branch.',
|
|
25
|
+
rollbackFailed: 'Git could not restore all local changes. Check the working tree and Git status.',
|
|
26
|
+
remoteFailed: 'The Git operation failed. Check the selected connection and retry.',
|
|
27
|
+
remoteDone: 'Git operation completed.',
|
|
28
|
+
pulledProject: 'Pull completed. Use Reload Project to open the updated files.',
|
|
29
|
+
confirmPush: 'Push the current branch to its GitHub origin?',
|
|
30
|
+
failed: 'Could not read the repository. Check folder access and refresh.'
|
|
31
|
+
},
|
|
32
|
+
ja: {
|
|
33
|
+
pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
|
|
34
|
+
title: 'ローカル Git', menu: 'ローカル Git…', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
|
|
35
|
+
empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
|
|
36
|
+
repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
|
|
37
|
+
open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
|
|
38
|
+
replace: '未保存の変更を破棄して、別の作品を開きますか?',
|
|
39
|
+
dirty: 'Editor に未保存の変更があります。', saved: 'Editor に未保存の変更はありません。',
|
|
40
|
+
staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミットの変更',
|
|
41
|
+
history: '履歴', worktree: '作業ツリー', clean: '対象フォルダに変更はありません。',
|
|
42
|
+
branches: 'ローカルブランチ', unborn: 'コミットはまだありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
|
|
43
|
+
binary: 'バイナリアセット', large: 'テキスト差分の表示上限を超えています。', bytes: 'バイト',
|
|
44
|
+
mode: 'Git のファイルモード', permissions: '作業ツリーの実行権限の変更はブラウザーから確認できません。',
|
|
45
|
+
unchanged: 'テキストに変更はありません。', loading: 'リポジトリを読込中…',
|
|
46
|
+
invalid: '.git ディレクトリを持つリポジトリと、その配下の作品を選んでください。',
|
|
47
|
+
changed: '読込中にファイルまたは Git の状態が変わりました。再取得してください。',
|
|
48
|
+
outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
|
|
49
|
+
unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
|
|
50
|
+
pullUnsafe: 'pull の前に実行を止め、未保存編集とローカルの Git 変更を解消してください。',
|
|
51
|
+
diverged: 'fast-forward できません。ローカルブランチとファイルは保持されています。',
|
|
52
|
+
session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
|
|
53
|
+
pushUnknown: 'push の結果が不明です。再試行する前に fetch でリモートブランチを確認してください。',
|
|
54
|
+
pushRefresh: 'push は成功しました。fetch でローカルの追跡ブランチを更新してください。',
|
|
55
|
+
rollbackFailed: 'Git がローカルの変更をすべて復元できませんでした。作業ツリーと Git の状態を確認してください。',
|
|
56
|
+
remoteFailed: 'Git 操作に失敗しました。選択中の接続を確認して再試行してください。',
|
|
57
|
+
remoteDone: 'Git 操作が完了しました。',
|
|
58
|
+
pulledProject: 'pull が完了しました。「作品を再読込」で更新済みのファイルを開けます。',
|
|
59
|
+
confirmPush: '現在のブランチを GitHub の origin に push しますか?',
|
|
60
|
+
failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
export type Labels = typeof messages.en;
|
|
64
|
+
export const labelsFor = (locale: string): Labels => locale.startsWith('ja') ? messages.ja : messages.en;
|