@surenkid/acli-hub 1.0.6 → 1.0.8
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/.env.example +4 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/server/index.js +2 -0
- package/server/public/assets/PreviewDialog-CyWZ9PNI.js +2 -0
- package/server/public/assets/PreviewDialog-Du15-2g0.css +1 -0
- package/server/public/assets/index-CBc7nL_R.js +17 -0
- package/server/public/assets/index-DNFZqhmU.css +1 -0
- package/server/public/index.html +2 -2
- package/server/routes/auth.js +9 -2
- package/server/routes/auth.test.js +71 -0
- package/server/routes/files.js +10 -0
- package/server/routes/files.test.js +28 -0
- package/server/routes/git.js +19 -0
- package/server/routes/git.test.js +33 -0
- package/server/services/file-paths.js +48 -0
- package/server/services/git.js +169 -0
- package/server/services/git.test.js +146 -0
- package/server/public/assets/PreviewDialog-83b2IRO1.js +0 -2
- package/server/public/assets/PreviewDialog-mYkQ2B4R.css +0 -1
- package/server/public/assets/index-C_n2nSEM.css +0 -1
- package/server/public/assets/index-DhBFQikm.js +0 -7
package/server/routes/auth.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
|
+
const os = require('os');
|
|
2
3
|
const { sessionCookie, clearCookie, sessionTokenFromRequest } = require('../services/auth');
|
|
3
4
|
|
|
4
|
-
module.exports = function (auth) {
|
|
5
|
+
module.exports = function (auth, { name = process.env.ACLI_HUB_NAME, hostname = os.hostname() } = {}) {
|
|
5
6
|
const router = express.Router();
|
|
7
|
+
const normalizeName = value => (typeof value === 'string' ? value : '')
|
|
8
|
+
.replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 100);
|
|
9
|
+
const instanceName = normalizeName(name) || normalizeName(hostname) || 'localhost';
|
|
6
10
|
|
|
7
11
|
router.get('/status', (req, res) => {
|
|
8
12
|
const authRequired = auth.isEnabled();
|
|
9
13
|
const authenticated = !authRequired || auth.validateSession(sessionTokenFromRequest(req));
|
|
10
|
-
|
|
14
|
+
// The login page needs the same tab identity as an authenticated page.
|
|
15
|
+
// Return only the chosen display name, not the hostname behind an alias.
|
|
16
|
+
res.set('Cache-Control', 'no-store');
|
|
17
|
+
res.json({ authRequired, authenticated, instance: { name: instanceName } });
|
|
11
18
|
});
|
|
12
19
|
|
|
13
20
|
router.post('/login', (req, res) => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const { AuthService } = require('../services/auth');
|
|
6
|
+
const authRoute = require('./auth');
|
|
7
|
+
|
|
8
|
+
async function serve(t, auth, identity) {
|
|
9
|
+
const app = express();
|
|
10
|
+
app.use(express.json());
|
|
11
|
+
app.use('/api/auth', authRoute(auth, identity));
|
|
12
|
+
const server = http.createServer(app);
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
server.once('error', reject);
|
|
15
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
16
|
+
});
|
|
17
|
+
t.after(async () => {
|
|
18
|
+
server.closeAllConnections();
|
|
19
|
+
await new Promise(resolve => server.close(resolve));
|
|
20
|
+
});
|
|
21
|
+
const base = `http://127.0.0.1:${server.address().port}/api/auth`;
|
|
22
|
+
return (path, options) => fetch(`${base}${path}`, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('hostname identity is available without authentication and status is never cached', async t => {
|
|
26
|
+
const request = await serve(t, new AuthService(), { name: '', hostname: 'dev-server' });
|
|
27
|
+
const response = await request('/status');
|
|
28
|
+
assert.equal(response.status, 200);
|
|
29
|
+
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
30
|
+
assert.deepEqual(await response.json(), {
|
|
31
|
+
authRequired: false, authenticated: true, instance: { name: 'dev-server' },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('custom names are public before login, hide the hostname and survive login/logout unchanged', async t => {
|
|
36
|
+
const identity = { name: ' 东京\t开发机\n🚀 ', hostname: 'private.internal.example' };
|
|
37
|
+
const request = await serve(t, new AuthService('fixture-password'), identity);
|
|
38
|
+
identity.name = 'Not the startup name';
|
|
39
|
+
let response = await request('/status');
|
|
40
|
+
let data = await response.json();
|
|
41
|
+
assert.deepEqual(data, {
|
|
42
|
+
authRequired: true, authenticated: false, instance: { name: '东京 开发机 🚀' },
|
|
43
|
+
});
|
|
44
|
+
assert.ok(!JSON.stringify(data).includes(identity.hostname));
|
|
45
|
+
|
|
46
|
+
response = await request('/login', {
|
|
47
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ password: 'fixture-password' }),
|
|
49
|
+
});
|
|
50
|
+
assert.equal(response.status, 200);
|
|
51
|
+
const cookie = response.headers.get('set-cookie').split(';')[0];
|
|
52
|
+
data = await (await request('/status', { headers: { Cookie: cookie } })).json();
|
|
53
|
+
assert.equal(data.authenticated, true);
|
|
54
|
+
assert.deepEqual(data.instance, { name: '东京 开发机 🚀' });
|
|
55
|
+
|
|
56
|
+
await request('/logout', { method: 'POST', headers: { Cookie: cookie } });
|
|
57
|
+
data = await (await request('/status', { headers: { Cookie: cookie } })).json();
|
|
58
|
+
assert.equal(data.authenticated, false);
|
|
59
|
+
assert.deepEqual(data.instance, { name: '东京 开发机 🚀' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('empty/invalid names fall back safely, control characters are removed and names are bounded', async t => {
|
|
63
|
+
for (const name of ['', ' \t\n\0 ', null, 123]) {
|
|
64
|
+
const request = await serve(t, new AuthService(), { name, hostname: 'host-fallback' });
|
|
65
|
+
assert.deepEqual((await (await request('/status')).json()).instance, { name: 'host-fallback' });
|
|
66
|
+
}
|
|
67
|
+
const long = await serve(t, new AuthService(), { name: 'a'.repeat(200), hostname: 'host' });
|
|
68
|
+
assert.equal((await (await long('/status')).json()).instance.name, 'a'.repeat(100));
|
|
69
|
+
const empty = await serve(t, new AuthService(), { name: '', hostname: '' });
|
|
70
|
+
assert.equal((await (await empty('/status')).json()).instance.name, 'localhost');
|
|
71
|
+
});
|
package/server/routes/files.js
CHANGED
|
@@ -10,6 +10,7 @@ const archiver = require('archiver');
|
|
|
10
10
|
const { execFile } = require('child_process');
|
|
11
11
|
const { promisify } = require('util');
|
|
12
12
|
const moveFile = promisify(execFile);
|
|
13
|
+
const { scopedPath, entryMetadata } = require('../services/file-paths');
|
|
13
14
|
|
|
14
15
|
const TEXT_LIMIT = 2 * 1024 * 1024;
|
|
15
16
|
const DOCX_LIMIT = 10 * 1024 * 1024;
|
|
@@ -111,8 +112,17 @@ module.exports = function filesRoute({ platform = process.platform, home = os.ho
|
|
|
111
112
|
res.json({ path: directory, entries, trash });
|
|
112
113
|
}));
|
|
113
114
|
|
|
115
|
+
router.get('/stat', route(async (req, res) => {
|
|
116
|
+
const target = await scopedPath(req.query.root, req.query.path);
|
|
117
|
+
const stat = await fsp.lstat(target);
|
|
118
|
+
if (!stat.isFile()) throw httpError(400, '链接目标不是可预览的普通文件');
|
|
119
|
+
res.set('Cache-Control', 'no-store');
|
|
120
|
+
res.json(entryMetadata(target, stat));
|
|
121
|
+
}));
|
|
122
|
+
|
|
114
123
|
router.get('/content', route(async (req, res) => {
|
|
115
124
|
const target = filePath(req.query.path);
|
|
125
|
+
if (req.query.root !== undefined) await scopedPath(req.query.root, target);
|
|
116
126
|
const stat = await fsp.lstat(target);
|
|
117
127
|
if (!stat.isFile()) throw httpError(400, 'Only regular files can be previewed or downloaded');
|
|
118
128
|
const ext = path.extname(target).slice(1).toLowerCase();
|
|
@@ -126,6 +126,34 @@ test('listing includes dotfiles and symlinks without following links; content su
|
|
|
126
126
|
assert.equal(response.status, 400);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test('document metadata and scoped content reject traversal and symlinks in any component', async t => {
|
|
130
|
+
const { directory, request } = await serve(t);
|
|
131
|
+
const root = path.join(directory, 'project');
|
|
132
|
+
await fs.mkdir(path.join(root, 'docs'), { recursive: true });
|
|
133
|
+
const target = path.join(root, 'docs/中文 #?.md');
|
|
134
|
+
await fs.writeFile(target, '# hello');
|
|
135
|
+
await fs.writeFile(path.join(directory, 'outside.md'), 'outside');
|
|
136
|
+
let response = await request(`/stat${query({ root, path: target })}`);
|
|
137
|
+
assert.equal(response.status, 200);
|
|
138
|
+
assert.equal((await response.json()).name, '中文 #?.md');
|
|
139
|
+
response = await request(`/content${query({ root, path: target })}`);
|
|
140
|
+
assert.equal(await response.text(), '# hello');
|
|
141
|
+
for (const endpoint of ['stat', 'content']) {
|
|
142
|
+
response = await request(`/${endpoint}${query({ root, path: path.join(directory, 'outside.md') })}`);
|
|
143
|
+
assert.equal(response.status, 403);
|
|
144
|
+
}
|
|
145
|
+
await fs.symlink(directory, path.join(root, 'linked'));
|
|
146
|
+
response = await request(`/stat${query({ root, path: path.join(root, 'linked/outside.md') })}`);
|
|
147
|
+
assert.equal(response.status, 403);
|
|
148
|
+
await fs.symlink(target, path.join(root, 'link.md'));
|
|
149
|
+
response = await request(`/content${query({ root, path: path.join(root, 'link.md') })}`);
|
|
150
|
+
assert.equal(response.status, 403);
|
|
151
|
+
response = await request(`/stat${query({ root, path: path.join(root, 'missing.md') })}`);
|
|
152
|
+
assert.equal(response.status, 404);
|
|
153
|
+
response = await request(`/stat${query({ root, path: path.join(root, 'docs') })}`);
|
|
154
|
+
assert.equal(response.status, 400);
|
|
155
|
+
});
|
|
156
|
+
|
|
129
157
|
test('active content is isolated on direct navigation; large text/DOCX require download', async t => {
|
|
130
158
|
const { directory, request } = await serve(t);
|
|
131
159
|
for (const filename of ['page.html', 'image.svg']) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { createGitService } = require('../services/git');
|
|
3
|
+
|
|
4
|
+
module.exports = function gitRoute(service = createGitService()) {
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
function route(work) {
|
|
7
|
+
return async (req, res) => {
|
|
8
|
+
res.set('Cache-Control', 'no-store');
|
|
9
|
+
try { res.json(await work(req)); }
|
|
10
|
+
catch (err) {
|
|
11
|
+
const status = err.status || ({ ENOENT: 404, EACCES: 403, EPERM: 403, ENOTDIR: 400 })[err.code] || 500;
|
|
12
|
+
res.status(status).json({ error: err.message });
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
router.get('/status', route(req => service.status(req.query.root)));
|
|
17
|
+
router.get('/diff', route(req => service.diff(req.query.root, req.query.path)));
|
|
18
|
+
return router;
|
|
19
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const gitRoute = require('./git');
|
|
6
|
+
|
|
7
|
+
test('Git routes use no-store, preserve unavailable/failed states and validate missing arguments', async t => {
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use('/api/git', gitRoute({
|
|
10
|
+
status(root) {
|
|
11
|
+
if (!root) throw Object.assign(new Error('root required'), { status: 400 });
|
|
12
|
+
if (root === '/failure') throw Object.assign(new Error('Git timeout'), { status: 504 });
|
|
13
|
+
return { available: false, reason: 'not-repository', changes: [] };
|
|
14
|
+
},
|
|
15
|
+
diff(root, path) { return { change: { path, status: 'M' }, patch: '+text', notice: '', root }; },
|
|
16
|
+
}));
|
|
17
|
+
const server = http.createServer(app);
|
|
18
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
19
|
+
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); });
|
|
20
|
+
const base = `http://127.0.0.1:${server.address().port}/api/git`;
|
|
21
|
+
let response = await fetch(`${base}/status?root=/plain`);
|
|
22
|
+
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
23
|
+
assert.equal((await response.json()).reason, 'not-repository');
|
|
24
|
+
response = await fetch(`${base}/status`);
|
|
25
|
+
assert.equal(response.status, 400);
|
|
26
|
+
response = await fetch(`${base}/status?root=/failure`);
|
|
27
|
+
assert.equal(response.status, 504);
|
|
28
|
+
assert.equal((await response.json()).error, 'Git timeout');
|
|
29
|
+
response = await fetch(`${base}/diff?${new URLSearchParams({ root: '/项目', path: '/项目/a #?.md' })}`);
|
|
30
|
+
const diff = await response.json();
|
|
31
|
+
assert.equal(diff.change.path, '/项目/a #?.md');
|
|
32
|
+
assert.equal(diff.root, '/项目');
|
|
33
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
function httpError(status, message) {
|
|
5
|
+
return Object.assign(new Error(message), { status });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function absolutePath(value) {
|
|
9
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value)) {
|
|
10
|
+
throw httpError(400, 'An absolute path without null bytes is required');
|
|
11
|
+
}
|
|
12
|
+
return path.resolve(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isWithin(root, target) {
|
|
16
|
+
const relative = path.relative(root, target);
|
|
17
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Document navigation is narrower than the general authenticated file APIs:
|
|
21
|
+
// never escape the pin, or follow a link anywhere beneath it. System aliases
|
|
22
|
+
// above the pin (e.g. /var -> /private/var on macOS) are still valid roots.
|
|
23
|
+
async function scopedPath(rootValue, targetValue, { allowMissing = false } = {}) {
|
|
24
|
+
const root = absolutePath(rootValue);
|
|
25
|
+
const target = absolutePath(targetValue);
|
|
26
|
+
if (!isWithin(root, target)) throw httpError(403, '链接超出了当前项目目录');
|
|
27
|
+
const rootStat = await fs.lstat(root);
|
|
28
|
+
if (!rootStat.isDirectory()) throw httpError(400, '项目根目录必须是普通目录,不能是符号链接');
|
|
29
|
+
let current = root;
|
|
30
|
+
for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
|
|
31
|
+
current = path.join(current, part);
|
|
32
|
+
try {
|
|
33
|
+
const stat = await fs.lstat(current);
|
|
34
|
+
if (stat.isSymbolicLink()) throw httpError(403, '不支持通过符号链接打开文件');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (allowMissing && err.code === 'ENOENT') return target;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function entryMetadata(target, stat) {
|
|
44
|
+
return { name: path.basename(target), path: target, size: stat.size, mtime: stat.mtime.toISOString(),
|
|
45
|
+
type: stat.isSymbolicLink() ? 'symlink' : stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { absolutePath, entryMetadata, httpError, isWithin, scopedPath };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFile } = require('node:child_process');
|
|
5
|
+
const { absolutePath, httpError, isWithin, scopedPath } = require('./file-paths');
|
|
6
|
+
|
|
7
|
+
const DIFF_LIMIT = 2 * 1024 * 1024;
|
|
8
|
+
const COMMAND_TIMEOUT = 5000;
|
|
9
|
+
const CONFLICTS = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
10
|
+
|
|
11
|
+
function parseStatus(output, repository, root) {
|
|
12
|
+
const records = output.split('\0');
|
|
13
|
+
const changes = new Map();
|
|
14
|
+
for (let i = 0; i < records.length; i++) {
|
|
15
|
+
const record = records[i];
|
|
16
|
+
if (!record) continue;
|
|
17
|
+
const xy = record.slice(0, 2);
|
|
18
|
+
const target = path.resolve(repository, record.slice(3));
|
|
19
|
+
const originalPath = /[RC]/.test(xy) ? path.resolve(repository, records[++i]) : undefined;
|
|
20
|
+
if (!isWithin(root, target)) continue;
|
|
21
|
+
const status = CONFLICTS.has(xy) ? 'C' : xy === '??' ? 'U' : xy.includes('R') ? 'R' :
|
|
22
|
+
xy.includes('D') ? 'D' : xy.includes('A') ? 'A' : 'M';
|
|
23
|
+
const previous = changes.get(target);
|
|
24
|
+
// An index deletion followed by an untracked replacement has two records
|
|
25
|
+
// for one path. Keep both facts; HEAD -> disk may actually be unchanged.
|
|
26
|
+
changes.set(target, { path: target, status: previous ? 'M' : status,
|
|
27
|
+
index: previous && previous.index !== '?' ? previous.index : xy[0],
|
|
28
|
+
worktree: previous?.worktree === '?' ? '?' : xy[1],
|
|
29
|
+
deleted: !previous && (xy[1] === 'D' || (xy[0] === 'D' && xy[1] === ' ')),
|
|
30
|
+
...(originalPath ? { originalPath } : {}), ...(previous ? { replacement: true } : {}) });
|
|
31
|
+
}
|
|
32
|
+
return [...changes.values()];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gitError(error) {
|
|
36
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return httpError(413, 'Git 输出超过大小限制,请在终端查看');
|
|
37
|
+
if (error.killed || error.code === 'ETIMEDOUT') return httpError(504, 'Git 查询超过 5 秒,请在终端查看或稍后重试');
|
|
38
|
+
return httpError(503, 'Git 查询失败,请检查仓库状态后刷新');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createGitService({ executable = 'git' } = {}) {
|
|
42
|
+
const inFlight = new Map();
|
|
43
|
+
// Do not inherit a CLI's alternate repository/index/pathspec environment.
|
|
44
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
45
|
+
env.PATH = `${env.PATH || ''}:/opt/homebrew/bin:/usr/local/bin`;
|
|
46
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
47
|
+
env.LC_ALL = 'C';
|
|
48
|
+
|
|
49
|
+
async function run(cwd, args, { accepted = [0], limit = DIFF_LIMIT, encoding = 'utf8' } = {}) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
execFile(executable, ['--no-pager', '--no-optional-locks', '--literal-pathspecs', '-c', 'core.fsmonitor=false',
|
|
52
|
+
'-c', 'core.untrackedCache=false', '-C', cwd, ...args],
|
|
53
|
+
{ env, encoding, timeout: COMMAND_TIMEOUT, maxBuffer: limit }, (error, stdout, stderr) => {
|
|
54
|
+
if (!error || accepted.includes(error.code)) resolve({ stdout, stderr, code: error?.code || 0 });
|
|
55
|
+
else reject(Object.assign(error, { stderr }));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function queryStatus(root) {
|
|
61
|
+
await scopedPath(root, root);
|
|
62
|
+
let repository;
|
|
63
|
+
try {
|
|
64
|
+
repository = (await run(root, ['rev-parse', '--show-toplevel'])).stdout.replace(/\r?\n$/, '');
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'ENOENT') return { available: false, reason: 'git-unavailable', changes: [] };
|
|
67
|
+
if (err.code === 128 && /not a git repository/.test(err.stderr)) return { available: false, reason: 'not-repository', changes: [] };
|
|
68
|
+
throw gitError(err);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const result = await run(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=all', '--renames', '--', '.'], { limit: 4 * DIFF_LIMIT });
|
|
72
|
+
// Git reports canonical paths; keep the caller's pin spelling in the UI.
|
|
73
|
+
const canonicalRoot = await fs.realpath(root);
|
|
74
|
+
const changes = parseStatus(result.stdout, repository, canonicalRoot).map(change => ({ ...change,
|
|
75
|
+
path: path.join(root, path.relative(canonicalRoot, change.path)),
|
|
76
|
+
...(change.originalPath ? { originalPath: path.join(root, path.relative(canonicalRoot, change.originalPath)) } : {}) }));
|
|
77
|
+
return { available: true, repository, changes };
|
|
78
|
+
} catch (err) { throw err.status ? err : gitError(err); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function status(rootValue) {
|
|
82
|
+
const root = absolutePath(rootValue);
|
|
83
|
+
if (!inFlight.has(root)) {
|
|
84
|
+
const pending = queryStatus(root).finally(() => inFlight.delete(root));
|
|
85
|
+
inFlight.set(root, pending);
|
|
86
|
+
}
|
|
87
|
+
return inFlight.get(root);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function diff(rootValue, targetValue) {
|
|
91
|
+
const root = absolutePath(rootValue);
|
|
92
|
+
const target = absolutePath(targetValue);
|
|
93
|
+
await scopedPath(root, target, { allowMissing: true });
|
|
94
|
+
const state = await status(root);
|
|
95
|
+
if (!state.available) return { change: null, patch: '', notice: state.reason === 'not-repository' ? '当前目录不在 Git 仓库中' : '未安装 Git,无法查看差异' };
|
|
96
|
+
const change = state.changes.find(item => item.path === target);
|
|
97
|
+
if (!change) return { change: null, patch: '', notice: '当前文件没有未提交改动' };
|
|
98
|
+
let stat;
|
|
99
|
+
try { stat = await fs.lstat(target); } catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
100
|
+
if (stat && !stat.isFile()) return { change, patch: '', notice: '此类型不支持文本差异;不会读取符号链接的目标' };
|
|
101
|
+
if (stat?.size > DIFF_LIMIT) return { change, patch: '', notice: '文件超过 2 MiB,请在终端查看差异' };
|
|
102
|
+
let temporary;
|
|
103
|
+
try {
|
|
104
|
+
const head = await run(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], { accepted: [0, 1] });
|
|
105
|
+
const canonicalRoot = await fs.realpath(root);
|
|
106
|
+
const canonicalTarget = path.join(canonicalRoot, path.relative(root, target));
|
|
107
|
+
const original = change.originalPath && isWithin(root, change.originalPath) ? change.originalPath : target;
|
|
108
|
+
const canonicalOriginal = path.join(canonicalRoot, path.relative(root, original));
|
|
109
|
+
const relativeOriginal = path.relative(state.repository, canonicalOriginal);
|
|
110
|
+
let originalMode;
|
|
111
|
+
if (head.code === 0) {
|
|
112
|
+
const tree = await run(state.repository, ['ls-tree', '-l', '-z', 'HEAD', '--', relativeOriginal]);
|
|
113
|
+
originalMode = tree.stdout.slice(0, 6);
|
|
114
|
+
const size = /^\d+ blob \w+\s+(\d+)\t/.exec(tree.stdout)?.[1];
|
|
115
|
+
if (Number(size) > DIFF_LIMIT) return { change, patch: '', notice: '上次提交的文件超过 2 MiB,请在终端查看差异' };
|
|
116
|
+
if (originalMode === '160000') return { change, patch: '', notice: '子模块不支持文件文本差异' };
|
|
117
|
+
}
|
|
118
|
+
const common = ['--no-ext-diff', '--no-textconv', '--no-color', '--unified=3'];
|
|
119
|
+
let result;
|
|
120
|
+
let synthetic = false;
|
|
121
|
+
if (change.replacement && head.code === 0 && stat) {
|
|
122
|
+
// Git normally treats this path as deleted because it is absent from
|
|
123
|
+
// the index. Compare the actual HEAD bytes with the replacement.
|
|
124
|
+
temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'hub-git-diff-'));
|
|
125
|
+
const before = path.join(temporary, 'before');
|
|
126
|
+
const originalContent = await run(root, ['show', `HEAD:${relativeOriginal}`], { encoding: 'buffer' });
|
|
127
|
+
await fs.writeFile(before, originalContent.stdout);
|
|
128
|
+
await fs.chmod(before, originalMode === '100755' ? 0o755 : 0o644);
|
|
129
|
+
result = await run(root, ['diff', '--no-index', ...common, '--', before, target], { accepted: [0, 1] });
|
|
130
|
+
synthetic = true;
|
|
131
|
+
} else if (head.code !== 0 || change.status === 'U') {
|
|
132
|
+
if (!stat) return { change, patch: '', notice: '文件已经不存在,请刷新目录' };
|
|
133
|
+
result = await run(root, ['diff', '--no-index', ...common, '--', os.devNull, target], { accepted: [0, 1] });
|
|
134
|
+
synthetic = true;
|
|
135
|
+
} else {
|
|
136
|
+
const paths = [path.relative(canonicalRoot, canonicalTarget)];
|
|
137
|
+
if (original !== target) paths.push(path.relative(root, original));
|
|
138
|
+
result = await run(root, ['diff', ...common, '--find-renames', 'HEAD', '--', ...paths]);
|
|
139
|
+
}
|
|
140
|
+
const binary = /^(?:Binary files .* differ|GIT binary patch)$/m.test(result.stdout);
|
|
141
|
+
let lines = 0;
|
|
142
|
+
for (const character of result.stdout) {
|
|
143
|
+
if (character === '\n' && ++lines > 20000) return { change, patch: '', notice: '差异超过 20,000 行,请在终端查看,避免浏览器卡顿' };
|
|
144
|
+
}
|
|
145
|
+
let patch = result.stdout;
|
|
146
|
+
if (synthetic && patch && !binary) {
|
|
147
|
+
// Hide temporary/absolute paths in no-index headers. Keep logical Git
|
|
148
|
+
// paths, escaping special filenames without touching hunk contents.
|
|
149
|
+
const relative = path.relative(state.repository, canonicalTarget);
|
|
150
|
+
let inHunk = false;
|
|
151
|
+
patch = patch.split('\n').map(line => {
|
|
152
|
+
if (line.startsWith('@@ ')) inHunk = true;
|
|
153
|
+
if (inHunk) return line;
|
|
154
|
+
if (line.startsWith('diff --git ')) return `diff --git ${JSON.stringify(`a/${relative}`)} ${JSON.stringify(`b/${relative}`)}`;
|
|
155
|
+
if (line.startsWith('--- ')) return `--- ${change.replacement ? JSON.stringify(`a/${relative}`) : '/dev/null'}`;
|
|
156
|
+
if (line.startsWith('+++ ')) return `+++ ${JSON.stringify(`b/${relative}`)}`;
|
|
157
|
+
return line;
|
|
158
|
+
}).join('\n');
|
|
159
|
+
}
|
|
160
|
+
return { change, patch: binary ? '' : patch,
|
|
161
|
+
notice: binary ? '二进制文件不支持文本差异' : !result.stdout ? '相对上次提交没有净差异(暂存区与工作区的改动可能相互抵消)' : '' };
|
|
162
|
+
} catch (err) { throw err.status ? err : gitError(err); }
|
|
163
|
+
finally { if (temporary) await fs.rm(temporary, { recursive: true, force: true }); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { status, diff };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { createGitService, parseStatus, DIFF_LIMIT };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { execFile } = require('node:child_process');
|
|
7
|
+
const { promisify } = require('node:util');
|
|
8
|
+
const { createGitService, parseStatus, DIFF_LIMIT } = require('./git');
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function repository(t) {
|
|
12
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'hub-git-中文-'));
|
|
13
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
14
|
+
const env = { ...process.env, GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_NOSYSTEM: '1' };
|
|
15
|
+
const git = (...args) => execute('git', ['-C', root, '-c', 'user.name=Hub Test', '-c', 'user.email=test@example.invalid',
|
|
16
|
+
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', ...args], { env });
|
|
17
|
+
await git('init', '-q');
|
|
18
|
+
const write = (name, content) => fs.writeFile(path.join(root, name), content);
|
|
19
|
+
const commit = async () => { await git('add', '--all'); await git('commit', '-qm', 'fixture'); };
|
|
20
|
+
return { root, git, write, commit, service: createGitService() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('porcelain -z parsing preserves special names, rename origins and conflict codes', () => {
|
|
24
|
+
const root = '/project';
|
|
25
|
+
const changes = parseStatus(' M 中文 \t\n?#.txt\0R new name\0old name\0UU conflict\0 D folder/deleted\0?? fresh\0 M ../outside\0', root, root);
|
|
26
|
+
assert.equal(changes.length, 5);
|
|
27
|
+
assert.equal(changes[0].path, '/project/中文 \t\n?#.txt');
|
|
28
|
+
assert.equal(changes[1].originalPath, '/project/old name');
|
|
29
|
+
assert.equal(changes[1].status, 'R');
|
|
30
|
+
assert.equal(changes[2].status, 'C');
|
|
31
|
+
assert.equal(changes[3].deleted, true);
|
|
32
|
+
assert.equal(changes[4].status, 'U');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('status and HEAD diff include staged AND unstaged edits without changing the index', async t => {
|
|
36
|
+
const { root, git, write, commit, service } = await repository(t);
|
|
37
|
+
await write('file.txt', 'base\n'); await commit();
|
|
38
|
+
await write('file.txt', 'staged\n'); await git('add', 'file.txt');
|
|
39
|
+
await write('file.txt', 'current\n');
|
|
40
|
+
const index = await fs.readFile(path.join(root, '.git/index'));
|
|
41
|
+
const state = await service.status(root);
|
|
42
|
+
assert.equal(state.available, true);
|
|
43
|
+
assert.equal(state.changes[0].index, 'M');
|
|
44
|
+
assert.equal(state.changes[0].worktree, 'M');
|
|
45
|
+
const result = await service.diff(root, path.join(root, 'file.txt'));
|
|
46
|
+
assert.match(result.patch, /-base\n\+current/);
|
|
47
|
+
assert.ok(!result.patch.includes('+staged'));
|
|
48
|
+
assert.deepEqual(await fs.readFile(path.join(root, '.git/index')), index);
|
|
49
|
+
await git('add', 'file.txt');
|
|
50
|
+
assert.match((await service.diff(root, path.join(root, 'file.txt'))).patch, /\+current/);
|
|
51
|
+
await git('commit', '-qm', 'done');
|
|
52
|
+
assert.deepEqual((await service.status(root)).changes, []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('unborn repositories and untracked files produce full additions, including literal Git pathspec names', async t => {
|
|
56
|
+
const { root, write, commit, service } = await repository(t);
|
|
57
|
+
await write(':(glob)* 中文.txt', 'first\nsecond\n');
|
|
58
|
+
let result = await service.diff(root, path.join(root, ':(glob)* 中文.txt'));
|
|
59
|
+
assert.match(result.patch, /\+first\n\+second/);
|
|
60
|
+
await commit();
|
|
61
|
+
await write('new.txt', 'new\n');
|
|
62
|
+
result = await service.diff(root, path.join(root, 'new.txt'));
|
|
63
|
+
assert.equal(result.change.status, 'U');
|
|
64
|
+
assert.match(result.patch, /\+new/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('deleted files, deleted directories, staged renames and pinned subdirectories are scoped correctly', async t => {
|
|
68
|
+
const { root, git, write, commit, service } = await repository(t);
|
|
69
|
+
await fs.mkdir(path.join(root, 'docs/nested'), { recursive: true });
|
|
70
|
+
await write('docs/nested/gone.txt', 'deleted content\n');
|
|
71
|
+
await write('docs/old.txt', 'rename content\n');
|
|
72
|
+
await write('outside.txt', 'outside\n'); await commit();
|
|
73
|
+
await fs.rm(path.join(root, 'docs/nested'), { recursive: true });
|
|
74
|
+
await git('mv', 'docs/old.txt', 'docs/new.txt');
|
|
75
|
+
await write('outside.txt', 'changed\n');
|
|
76
|
+
const pin = path.join(root, 'docs');
|
|
77
|
+
const state = await service.status(pin);
|
|
78
|
+
assert.equal(state.changes.length, 2);
|
|
79
|
+
assert.equal(state.changes.find(item => item.status === 'R').originalPath, path.join(pin, 'old.txt'));
|
|
80
|
+
const deletion = await service.diff(pin, path.join(pin, 'nested/gone.txt'));
|
|
81
|
+
assert.match(deletion.patch, /-deleted content/);
|
|
82
|
+
const rename = await service.diff(pin, path.join(pin, 'new.txt'));
|
|
83
|
+
assert.match(rename.patch, /rename from docs\/old.txt/);
|
|
84
|
+
await assert.rejects(service.diff(pin, path.join(root, 'outside.txt')), { status: 403 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('index deletion plus untracked replacement compares actual HEAD and disk, including cancellation', async t => {
|
|
88
|
+
const { root, git, write, commit, service } = await repository(t);
|
|
89
|
+
await write('file.txt', 'base\n'); await commit();
|
|
90
|
+
await git('rm', '--cached', 'file.txt');
|
|
91
|
+
let result = await service.diff(root, path.join(root, 'file.txt'));
|
|
92
|
+
assert.equal(result.change.replacement, true);
|
|
93
|
+
assert.equal(result.patch, '');
|
|
94
|
+
assert.match(result.notice, /没有净差异/);
|
|
95
|
+
await write('file.txt', 'replacement\n');
|
|
96
|
+
result = await service.diff(root, path.join(root, 'file.txt'));
|
|
97
|
+
assert.match(result.patch, /-base\n\+replacement/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('binary, large current files and large committed blobs have bounded diff handling', async t => {
|
|
101
|
+
const { root, write, commit, service } = await repository(t);
|
|
102
|
+
await write('binary.bin', Buffer.from([0, 1, 2]));
|
|
103
|
+
await write('old-large.txt', Buffer.alloc(DIFF_LIMIT + 1, 'a')); await commit();
|
|
104
|
+
await write('binary.bin', Buffer.from([0, 4, 5]));
|
|
105
|
+
await write('new-large.txt', Buffer.alloc(DIFF_LIMIT + 1, 'b'));
|
|
106
|
+
await write('old-large.txt', 'small\n');
|
|
107
|
+
assert.match((await service.diff(root, path.join(root, 'binary.bin'))).notice, /二进制/);
|
|
108
|
+
assert.match((await service.diff(root, path.join(root, 'new-large.txt'))).notice, /超过 2 MiB/);
|
|
109
|
+
assert.match((await service.diff(root, path.join(root, 'old-large.txt'))).notice, /上次提交/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('plain folders, missing git, invalid paths and symlink traversal fail distinctly', async t => {
|
|
113
|
+
const { root, service } = await repository(t);
|
|
114
|
+
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'hub-no-git-'));
|
|
115
|
+
t.after(() => fs.rm(plain, { recursive: true, force: true }));
|
|
116
|
+
assert.equal((await service.status(plain)).reason, 'not-repository');
|
|
117
|
+
assert.equal((await createGitService({ executable: path.join(plain, 'missing-git') }).status(root)).reason, 'git-unavailable');
|
|
118
|
+
assert.throws(() => service.status('relative'), { status: 400 });
|
|
119
|
+
await fs.symlink(plain, path.join(root, 'linked'));
|
|
120
|
+
await assert.rejects(service.diff(root, path.join(root, 'linked/file.txt')), { status: 403 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('unresolved merge conflicts have a conflict marker and remain readable as a diff', async t => {
|
|
124
|
+
const { root, git, write, commit, service } = await repository(t);
|
|
125
|
+
await write('file.txt', 'base\n'); await commit();
|
|
126
|
+
const branch = (await git('branch', '--show-current')).stdout.trim();
|
|
127
|
+
await git('checkout', '-qb', 'other'); await write('file.txt', 'other\n'); await commit();
|
|
128
|
+
await git('checkout', '-q', branch); await write('file.txt', 'main\n'); await commit();
|
|
129
|
+
await assert.rejects(git('merge', '--no-edit', 'other'));
|
|
130
|
+
const result = await service.diff(root, path.join(root, 'file.txt'));
|
|
131
|
+
assert.equal(result.change.status, 'C');
|
|
132
|
+
assert.match(result.patch, /<<<<<<< HEAD/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('Git failures are not clean states, concurrent requests are deduplicated and long patches are bounded', async t => {
|
|
136
|
+
const { root, write, service } = await repository(t);
|
|
137
|
+
const first = service.status(root);
|
|
138
|
+
const second = service.status(root);
|
|
139
|
+
assert.equal(first, second);
|
|
140
|
+
await first;
|
|
141
|
+
await assert.rejects(createGitService({ executable: process.execPath }).status(root), { status: 503 });
|
|
142
|
+
await write('many-lines.txt', 'line\n'.repeat(20001));
|
|
143
|
+
const result = await service.diff(root, path.join(root, 'many-lines.txt'));
|
|
144
|
+
assert.equal(result.patch, '');
|
|
145
|
+
assert.match(result.notice, /20,000 行/);
|
|
146
|
+
});
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/core-DchA7RCO.js","assets/_commonjsHelpers-Cpj98o6Y.js","assets/mammoth.browser-C_M0wsD2.js"])))=>i.map(i=>d[i]);
|
|
2
|
-
import{e as H,_ as e,a as S,u as U,r as u,p as N,o as K,b as G,c as t,d as J,f as i,t as c,g as $,h as I,i as z,n as Q,j as s,k as V,F as D,l as B,w as W,T as X,s as q,m as Y,q as Z,v as ee}from"./index-DhBFQikm.js";const te={javascript:()=>e(()=>import("./javascript-DUNaicwC.js"),[]),typescript:()=>e(()=>import("./typescript-5pldp2kO.js"),[]),xml:()=>e(()=>import("./xml-FcirFJJ2.js"),[]),css:()=>e(()=>import("./css-AVCICof-.js"),[]),scss:()=>e(()=>import("./scss-UZl0Ag-9.js"),[]),json:()=>e(()=>import("./json-DdsocbVI.js"),[]),python:()=>e(()=>import("./python-CXf-_D6G.js"),[]),bash:()=>e(()=>import("./bash-I8pq0VWm.js"),[]),yaml:()=>e(()=>import("./yaml-drsDnIKT.js"),[]),ini:()=>e(()=>import("./ini-B3BVgEsG.js"),[]),markdown:()=>e(()=>import("./markdown-fzgiUVUV.js"),[]),go:()=>e(()=>import("./go-4VmnRw3R.js"),[]),rust:()=>e(()=>import("./rust-DsMvwcVK.js"),[]),java:()=>e(()=>import("./java-DUnkFH8J.js"),[]),c:()=>e(()=>import("./c-D87IyN5J.js"),[]),cpp:()=>e(()=>import("./cpp-BFCWVspj.js"),[]),csharp:()=>e(()=>import("./csharp-D-6syieo.js"),[]),ruby:()=>e(()=>import("./ruby-CcVTKuP4.js"),[]),php:()=>e(()=>import("./php-kjAv1gW4.js"),[]),sql:()=>e(()=>import("./sql-BC88IN-V.js"),[]),swift:()=>e(()=>import("./swift-jkyvy0ac.js"),[]),kotlin:()=>e(()=>import("./kotlin-Za0-TyUR.js"),[]),lua:()=>e(()=>import("./lua-CD7o2Ci6.js"),[]),perl:()=>e(()=>import("./perl-BWtAWCfC.js"),[]),r:()=>e(()=>import("./r-DWz4c2Y4.js"),[]),dart:()=>e(()=>import("./dart-DbQNmsdu.js"),[]),dockerfile:()=>e(()=>import("./dockerfile-Co-AyC30.js"),[]),makefile:()=>e(()=>import("./makefile-BoJC05FH.js"),[]),diff:()=>e(()=>import("./diff-BLTryEFx.js"),[]),plaintext:()=>e(()=>import("./plaintext-CPM70vnn.js"),[])},se={js:"javascript",mjs:"javascript",cjs:"javascript",jsx:"javascript",ts:"typescript",tsx:"typescript",vue:"xml",svelte:"xml",html:"xml",htm:"xml",xml:"xml",svg:"xml",css:"css",scss:"scss",sass:"scss",less:"css",json:"json",jsonc:"json",py:"python",sh:"bash",zsh:"bash",bash:"bash",fish:"bash",yml:"yaml",yaml:"yaml",toml:"ini",ini:"ini",cfg:"ini",conf:"ini",md:"markdown",markdown:"markdown",go:"go",rs:"rust",java:"java",c:"c",h:"c",cpp:"cpp",cc:"cpp",hpp:"cpp",cs:"csharp",rb:"ruby",php:"php",sql:"sql",swift:"swift",kt:"kotlin",kts:"kotlin",lua:"lua",pl:"perl",r:"r",dart:"dart",diff:"diff",patch:"diff"};async function ae(r,k){if(new TextEncoder().encode(r).length>200*1024)return null;const d=k.toLowerCase(),_=d==="dockerfile"?"dockerfile":d==="makefile"?"makefile":se[H(k)];if(!_)return null;const[{default:h},{default:y}]=await Promise.all([e(()=>import("./core-DchA7RCO.js"),__vite__mapDeps([0,1])),te[_]()]);return h.getLanguage(_)||h.registerLanguage(_,y),h.highlight(r,{language:_,ignoreIllegals:!0}).value}const oe={class:"preview-layout"},re={class:"preview-header"},le={class:"preview-heading"},ie={id:"preview-title"},ne=["title"],ue=["href","download"],_e={key:0,class:"preview-message",role:"status"},ce={key:1,class:"preview-message error",role:"alert"},de={key:0,class:"preview-note"},ve={key:0,class:"preview-note"},me={class:"source"},pe=["innerHTML"],fe={key:1},he=["innerHTML"],ye=["srcdoc","title"],Ee=["src","alt"],we=["src"],ge=["src"],ke=["src","title"],be={key:8,class:"table-preview"},Te={class:"preview-note"},Le={class:"row-number"},De={key:9,class:"preview-message"},Pe={__name:"PreviewDialog",props:{entry:{type:Object,required:!0}},emits:["close"],setup(r,{emit:k}){const d=r,_=k,h=U(),y=u(null),a=u(N(d.entry.name)),P=u(!1),f=u(""),v=u(""),A=u(null),E=u(""),w=u([]),b=u(1e3),O=u(!1),g=ee(()=>z(d.entry.path)),R=new AbortController;let x;async function j(n){if(d.entry.size>n)throw new Error(`预览上限为 ${I(n)},请下载文件查看。`);const l=(await h.apiRequest(g.value,{signal:R.signal})).body.getReader(),m=[];let p=0;try{for(;;){const{done:L,value:M}=await l.read();if(L)break;if(p+=M.length,p>n)throw new Error(`预览上限为 ${I(n)},请下载文件查看。`);m.push(M)}}finally{await l.cancel().catch(()=>{})}const T=new Uint8Array(p);let C=0;for(const L of m)T.set(L,C),C+=L.length;return T}async function F(){if(["text","markdown","html","docx","table"].includes(a.value)){P.value=!0;try{if(a.value==="docx"){const n=await j(10485760),{default:o}=await e(async()=>{const{default:m}=await import("./mammoth.browser-C_M0wsD2.js").then(p=>p.m);return{default:m}},__vite__mapDeps([2,1])),l=await o.convertToHtml({arrayBuffer:n.buffer},{externalFileAccess:!1});E.value=q(l.value)}else{if(v.value=new TextDecoder().decode(await j(2*1024*1024)),v.value.includes("\0")){a.value="unsupported";return}if(a.value==="html")E.value=q(v.value);else if(a.value==="markdown")E.value=await Y(v.value);else if(a.value==="table"){const n=await Z(v.value,d.entry.name);n?w.value=n:(O.value=!0,a.value="text")}a.value==="text"&&(A.value=await ae(v.value,d.entry.name).catch(()=>null))}}catch(n){R.signal.aborted||(f.value=n.message)}finally{P.value=!1}}}return K(()=>{x=document.activeElement,y.value.showModal(),F()}),G(()=>{R.abort(),x?.focus()}),(n,o)=>(t(),J(X,{to:"body"},[i("dialog",{ref_key:"dialog",ref:y,class:"preview-dialog","aria-labelledby":"preview-title",onCancel:o[5]||(o[5]=W(l=>_("close"),["prevent"])),onClick:o[6]||(o[6]=l=>{l.target===y.value&&_("close")})},[i("div",oe,[i("header",re,[i("div",le,[i("h3",ie,c(r.entry.name),1),i("p",{title:r.entry.path},c(r.entry.path)+" · "+c($(I)(r.entry.size)),9,ne)]),i("a",{class:"btn",href:$(z)(r.entry.path,!0),download:r.entry.name},"下载",8,ue),i("button",{class:"close","aria-label":"关闭预览",autofocus:"",onClick:o[0]||(o[0]=l=>_("close"))},"×")]),i("main",{class:Q(["preview-body",{"frame-body":["html","docx","pdf"].includes(a.value)}])},[P.value?(t(),s("p",_e,"加载预览…")):f.value?(t(),s("p",ce,c(f.value),1)):(t(),s(D,{key:2},[O.value?(t(),s("p",de,"表格解析失败,已显示原始文本。")):V("",!0),a.value==="text"?(t(),s(D,{key:1},[r.entry.size>200*1024?(t(),s("p",ve,"文件超过 200 KiB,已使用纯文本预览。")):V("",!0),i("pre",me,[A.value!==null?(t(),s("code",{key:0,class:"hljs",innerHTML:A.value},null,8,pe)):(t(),s("code",fe,c(v.value),1))])],64)):a.value==="markdown"?(t(),s("article",{key:2,class:"markdown-body",innerHTML:E.value},null,8,he)):a.value==="html"||a.value==="docx"?(t(),s("iframe",{key:3,srcdoc:E.value,sandbox:"allow-scripts",referrerpolicy:"no-referrer",title:r.entry.name,class:"document-frame"},null,8,ye)):a.value==="image"?(t(),s("img",{key:4,src:g.value,alt:r.entry.name,class:"image-preview",onError:o[1]||(o[1]=l=>f.value="浏览器无法显示这张图片,请下载查看。")},null,40,Ee)):a.value==="audio"?(t(),s("audio",{key:5,src:g.value,controls:"",class:"media-preview",onError:o[2]||(o[2]=l=>f.value="浏览器不支持此音频格式,请下载查看。")},null,40,we)):a.value==="video"?(t(),s("video",{key:6,src:g.value,controls:"",playsinline:"",class:"media-preview",onError:o[3]||(o[3]=l=>f.value="浏览器不支持此视频格式,请下载查看。")},null,40,ge)):a.value==="pdf"?(t(),s("iframe",{key:7,src:g.value,title:r.entry.name,class:"document-frame"},null,8,ke)):a.value==="table"?(t(),s("div",be,[i("p",Te,"已显示 "+c(Math.min(b.value,w.value.length))+" / "+c(w.value.length)+" 行",1),i("table",null,[i("tbody",null,[(t(!0),s(D,null,B(w.value.slice(0,b.value),(l,m)=>(t(),s("tr",{key:m},[i("td",Le,c(m+1),1),(t(!0),s(D,null,B(l,(p,T)=>(t(),s("td",{key:T},c(p),1))),128))]))),128))])]),b.value<w.value.length?(t(),s("button",{key:0,class:"btn btn-primary show-more",onClick:o[4]||(o[4]=l=>b.value+=1e3)},"显示更多(1000 行)")):V("",!0)])):(t(),s("p",De,"此文件类型暂不支持预览,请下载查看。"))],64))],2)])],544)]))}},Re=S(Pe,[["__scopeId","data-v-c613172c"]]);export{Re as default};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.preview-dialog[data-v-c613172c]{margin:auto;width:min(1100px,calc(100vw - 32px));height:min(850px,calc(100dvh - 48px));max-width:none;max-height:none;padding:0;border:1px solid var(--border-color);border-radius:var(--radius-lg);background:var(--bg-primary);color:var(--text-primary)}.preview-dialog[data-v-c613172c]::backdrop{background:#000a;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.preview-layout[data-v-c613172c]{height:100%;display:flex;flex-direction:column}.preview-header[data-v-c613172c]{display:flex;align-items:center;padding:12px 16px;gap:12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary)}.preview-heading[data-v-c613172c]{flex:1;min-width:0}.preview-heading h3[data-v-c613172c],.preview-heading p[data-v-c613172c]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.preview-heading h3[data-v-c613172c]{font-size:15px}.preview-heading p[data-v-c613172c]{color:var(--text-secondary);font-size:11px}.preview-header a[data-v-c613172c]{color:var(--text-primary);text-decoration:none;padding:4px 8px;border:1px solid var(--border-color)}.close[data-v-c613172c]{border:none;color:var(--text-secondary);background:none;font-size:26px;cursor:pointer;padding:0 4px}.preview-body[data-v-c613172c]{flex:1;min-height:0;overflow:auto;padding:20px}.frame-body[data-v-c613172c]{padding:0;display:flex}.preview-message[data-v-c613172c]{padding:24px;color:var(--text-secondary)}.error[data-v-c613172c]{color:var(--danger)}.preview-note[data-v-c613172c]{color:var(--text-secondary);font-size:12px;margin-bottom:12px}.source[data-v-c613172c]{font:13px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:4}.source code[data-v-c613172c]{padding:0;background:transparent}.document-frame[data-v-c613172c]{border:none;background:#fff;width:100%;height:100%;min-height:0}.image-preview[data-v-c613172c]{display:block;max-width:100%;max-height:100%;object-fit:contain;margin:auto}.media-preview[data-v-c613172c]{display:block;width:100%;max-height:100%;margin:auto}table[data-v-c613172c]{border-collapse:collapse;font-size:12px}td[data-v-c613172c]{padding:5px 10px;border:1px solid var(--border-color);white-space:pre-wrap;min-width:60px;max-width:500px;overflow-wrap:anywhere}.row-number[data-v-c613172c]{color:var(--text-tertiary);text-align:right;min-width:35px;background:var(--bg-secondary)}.show-more[data-v-c613172c]{margin-top:16px}.markdown-body[data-v-c613172c]{line-height:1.7;overflow-wrap:anywhere}.markdown-body[data-v-c613172c] h1,.markdown-body[data-v-c613172c] h2,.markdown-body[data-v-c613172c] h3{margin:20px 0 12px}.markdown-body[data-v-c613172c] p,.markdown-body[data-v-c613172c] ul,.markdown-body[data-v-c613172c] ol,.markdown-body[data-v-c613172c] pre,.markdown-body[data-v-c613172c] table{margin-bottom:16px}.markdown-body[data-v-c613172c] ul,.markdown-body[data-v-c613172c] ol{padding-left:24px}.markdown-body[data-v-c613172c] a{color:#60a5fa}.markdown-body[data-v-c613172c] img{max-width:100%}.markdown-body[data-v-c613172c] pre,.markdown-body[data-v-c613172c] code{background:var(--bg-secondary);padding:2px 5px;border-radius:4px}.markdown-body[data-v-c613172c] pre{padding:12px;overflow:auto}.markdown-body[data-v-c613172c] blockquote{border-left:3px solid var(--border-color);color:var(--text-secondary);padding-left:14px}.markdown-body[data-v-c613172c] td,.markdown-body[data-v-c613172c] th{border:1px solid var(--border-color);padding:5px 10px}@media(max-width:600px){.preview-dialog[data-v-c613172c]{width:calc(100vw - 12px);height:calc(100dvh - 16px)}.preview-header[data-v-c613172c]{padding:10px}.preview-body[data-v-c613172c]:not(.frame-body){padding:12px}}
|