megit-app 0.1.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.
@@ -0,0 +1,144 @@
1
+ // %B (full message) contains arbitrary newlines, so it must be the last field
2
+ export const META_FORMAT = '%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B';
3
+ export function parseMeta(raw) {
4
+ const [author, authorEmail, authorDate, committer, committerEmail, commitDate, parents, ...rest] = raw.split('\x1f');
5
+ return {
6
+ author,
7
+ authorEmail,
8
+ authorDate: Number(authorDate),
9
+ committer,
10
+ committerEmail,
11
+ commitDate: Number(commitDate),
12
+ parents: parents ? parents.split(' ') : [],
13
+ message: rest.join('\x1f').replace(/\n+$/, ''),
14
+ };
15
+ }
16
+ // \x1f field sep, \x1e record sep — never appear in git metadata
17
+ // %ct (committer date), not %at: --date-order sorts by committer date, and stash
18
+ // placement bisects on commit.date — author dates go non-monotonic after rebase/revert
19
+ export const LOG_FORMAT = '%H%x1f%P%x1f%an%x1f%ae%x1f%ct%x1f%D%x1f%s%x1e';
20
+ export function parseLog(raw) {
21
+ return raw
22
+ .split('\x1e')
23
+ .map(r => r.replace(/^\n/, ''))
24
+ .filter(r => r.length > 0)
25
+ .map(rec => {
26
+ const [hash, parents, author, email, date, refs, subject] = rec.split('\x1f');
27
+ return {
28
+ hash,
29
+ parents: parents ? parents.split(' ') : [],
30
+ author,
31
+ email,
32
+ date: Number(date),
33
+ refs: refs ? refs.split(', ') : [],
34
+ subject,
35
+ };
36
+ });
37
+ }
38
+ // `/api/search` asks git for `%H%x1f%ct` — the hash to select and the date to order by.
39
+ export const parseMatches = (raw) => raw.split('\n').filter(Boolean).map(l => {
40
+ const [hash, ct] = l.split('\x1f');
41
+ return [hash, Number(ct)];
42
+ });
43
+ // The graph will not render 14k rows, and a one-word query can match most of a
44
+ // history, so the route reports a bounded slice.
45
+ export const SEARCH_CAP = 500;
46
+ // git ANDs its commit-limiting options, so "message OR author OR hash" costs one
47
+ // `git log` per field and a union here. Date-descending is the ordering the graph's
48
+ // --date-order mostly agrees with.
49
+ //
50
+ // ponytail: ct-desc, not true --date-order — topological tie-breaks can disagree, so
51
+ // "next" may occasionally step one row upward. Exact positions would cost a
52
+ // full-history `--format=%H` pass per query; local search is already exact.
53
+ export function mergeMatches(lists) {
54
+ const byHash = new Map();
55
+ for (const list of lists) {
56
+ for (const [hash, ct] of list)
57
+ if (!byHash.has(hash))
58
+ byHash.set(hash, ct);
59
+ }
60
+ const sorted = [...byHash].sort((a, b) => b[1] - a[1]).map(([hash]) => hash);
61
+ return { matches: sorted.slice(0, SEARCH_CAP), truncated: sorted.length > SEARCH_CAP };
62
+ }
63
+ // `stash@{N}` is positional and renumbers on every stash push/drop, so a stash
64
+ // action must map its sha to an index against a list read at action time — an
65
+ // index captured when the graph loaded can address a different stash by now.
66
+ // Exact match: a prefix could collide, and every caller has the full %H.
67
+ export const stashIndex = (raw, hash) => raw.split('\n').filter(Boolean).indexOf(hash);
68
+ // Everything below reads `-z` output: one NUL-terminated record per entry, headers
69
+ // included. Without it git C-quotes any path holding a non-ASCII byte, a tab or a
70
+ // `"` ("\303\274mlaut.txt"), and that literal can't go back to git as a pathspec —
71
+ // staging, discarding and diffing such a file all failed with "did not match any
72
+ // files". `-z` paths are raw, so they survive the round trip.
73
+ const records = (raw) => raw.split('\0');
74
+ // The `# branch.*` headers `git status --porcelain=v2 --branch` prepends to the same
75
+ // output parseStatus already reads — so the toolbar's ahead/behind badges cost no
76
+ // extra git process. git omits branch.upstream and branch.ab when there's no upstream.
77
+ export function parseBranchHeader(raw) {
78
+ const fields = new Map();
79
+ for (const r of records(raw)) {
80
+ if (!r.startsWith('# branch.'))
81
+ continue;
82
+ const sp = r.indexOf(' ', 9);
83
+ if (sp > 0)
84
+ fields.set(r.slice(9, sp), r.slice(sp + 1));
85
+ }
86
+ const head = fields.get('head') ?? null;
87
+ const ab = fields.get('ab')?.match(/^\+(\d+) -(\d+)$/);
88
+ return {
89
+ head: head === '(detached)' ? null : head,
90
+ upstream: fields.get('upstream') ?? null,
91
+ ahead: Number(ab?.[1] ?? 0),
92
+ behind: Number(ab?.[2] ?? 0),
93
+ };
94
+ }
95
+ export function parseStatus(raw) {
96
+ const recs = records(raw);
97
+ const out = [];
98
+ for (let i = 0; i < recs.length; i++) {
99
+ const line = recs[i];
100
+ if (!line)
101
+ continue;
102
+ const kind = line[0];
103
+ const parts = line.split(' ');
104
+ if (kind === '1') {
105
+ const xy = parts[1];
106
+ out.push({ path: parts.slice(8).join(' '), status: xy[1] !== '.' ? xy[1] : xy[0], x: xy[0], y: xy[1] });
107
+ }
108
+ else if (kind === '2') {
109
+ // rename/copy: extra score field, and under -z the original path is its own
110
+ // record rather than a \t-joined suffix — skip it, nothing here reads it
111
+ const xy = parts[1];
112
+ out.push({ path: parts.slice(9).join(' '), status: xy[0] === '.' ? xy[1] : xy[0], x: xy[0], y: xy[1] });
113
+ i++;
114
+ }
115
+ else if (kind === 'u') {
116
+ // conflicts are neither staged nor unstaged — they sit on the worktree side
117
+ // until resolved, so they show up under Changes and can't be committed as-is
118
+ out.push({ path: parts.slice(10).join(' '), status: 'U', x: '.', y: 'U' });
119
+ }
120
+ else if (kind === '?') {
121
+ out.push({ path: line.slice(2), status: '?', x: '.', y: '?' });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ // `git diff --name-status -z`: a status record then its path record, except
127
+ // rename/copy, which spends two paths (old, then new — the new one is what the
128
+ // commit's file list shows).
129
+ export function parseNameStatus(raw) {
130
+ const recs = records(raw);
131
+ const out = [];
132
+ for (let i = 0; i < recs.length; i++) {
133
+ const st = recs[i];
134
+ if (!st)
135
+ continue;
136
+ const paths = st[0] === 'R' || st[0] === 'C' ? 2 : 1;
137
+ // no path record (truncated output): a status with no file is nothing to show
138
+ if (!recs[i + paths])
139
+ break;
140
+ out.push({ status: st[0], path: recs[i + paths] });
141
+ i += paths;
142
+ }
143
+ return out;
144
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { loadConfig } from './config.js';
5
+ // node-pty is an optionalDependency: it ships prebuilds for darwin and win32 only,
6
+ // so a Linux install either compiles from source or omits the package entirely.
7
+ // resolve() answers "is it installed?" without executing the native binding — the
8
+ // lazy import in getSession() stays lazy, and /api/config stays cheap.
9
+ export function hasPty() {
10
+ try {
11
+ createRequire(import.meta.url).resolve('node-pty');
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ const MAX_BUFFER = 200 * 1024;
19
+ // keep the last ~cap chars: append chunk, evict oldest whole chunks; returns new size
20
+ export function pushCapped(buf, size, chunk, cap = MAX_BUFFER) {
21
+ buf.push(chunk);
22
+ size += chunk.length;
23
+ while (size > cap && buf.length > 1)
24
+ size -= buf.shift().length;
25
+ return size;
26
+ }
27
+ const sessions = new Map();
28
+ async function getSession(repo) {
29
+ const existing = sessions.get(repo);
30
+ if (existing)
31
+ return existing;
32
+ // dynamic import: the native module never loads until a terminal is actually opened
33
+ const { spawn } = await import('node-pty');
34
+ const shell = process.env.SHELL || '/bin/sh';
35
+ const pty = spawn(shell, ['-l'], {
36
+ name: 'xterm-256color',
37
+ cols: 80,
38
+ rows: 24,
39
+ cwd: repo,
40
+ env: process.env,
41
+ });
42
+ const s = { pty, buffer: [], size: 0, clients: new Set() };
43
+ sessions.set(repo, s);
44
+ pty.onData(d => {
45
+ s.size = pushCapped(s.buffer, s.size, d);
46
+ for (const ws of s.clients)
47
+ ws.send(d);
48
+ });
49
+ pty.onExit(() => {
50
+ sessions.delete(repo);
51
+ for (const ws of s.clients) {
52
+ ws.send('\r\n[session ended]\r\n');
53
+ ws.close();
54
+ }
55
+ });
56
+ return s;
57
+ }
58
+ async function attach(repo, ws) {
59
+ let s;
60
+ try {
61
+ s = await getSession(repo);
62
+ }
63
+ catch {
64
+ // resolve() said the package is there but the native binding failed to load
65
+ ws.send('\r\n[terminal unavailable: node-pty could not be loaded on this platform]\r\n');
66
+ ws.close();
67
+ return;
68
+ }
69
+ s.clients.add(ws);
70
+ if (s.buffer.length)
71
+ ws.send(s.buffer.join(''));
72
+ ws.on('message', raw => {
73
+ let msg;
74
+ try {
75
+ msg = JSON.parse(String(raw));
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ if (msg.t === 'i' && typeof msg.d === 'string')
81
+ s.pty.write(msg.d);
82
+ else if (msg.t === 'r' && msg.cols && msg.rows)
83
+ s.pty.resize(msg.cols, msg.rows);
84
+ else if (msg.t === 'c') {
85
+ s.buffer.length = 0;
86
+ s.size = 0;
87
+ }
88
+ });
89
+ ws.on('close', () => s.clients.delete(ws));
90
+ }
91
+ export function wireTerminal(server) {
92
+ const wss = new WebSocketServer({ noServer: true });
93
+ server.on('upgrade', (req, socket, head) => {
94
+ const url = new URL(req.url ?? '', 'http://localhost');
95
+ if (url.pathname !== '/api/term') {
96
+ socket.destroy();
97
+ return;
98
+ }
99
+ // WebSockets bypass CORS and this socket is a shell: only local pages may
100
+ // connect (no Origin header = non-browser client on this machine, allowed)
101
+ const origin = req.headers.origin;
102
+ if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
103
+ socket.destroy();
104
+ return;
105
+ }
106
+ const repo = url.searchParams.get('repo') ?? '';
107
+ if (!loadConfig().repos.includes(repo) || !existsSync(repo)) {
108
+ socket.destroy();
109
+ return;
110
+ }
111
+ wss.handleUpgrade(req, socket, head, ws => { void attach(repo, ws); });
112
+ });
113
+ }
@@ -0,0 +1,104 @@
1
+ // Filesystem watching for auto-refresh: which paths matter, and event debouncing.
2
+ import { existsSync, watch } from 'node:fs';
3
+ // dependency/build dirs git will never report on: an install or a bundler watch
4
+ // otherwise pins the debouncer's max-wait cap and refetches the graph every 2 s
5
+ const NOISE = /^(node_modules|dist|build|target|out|coverage|\.next|\.nuxt|\.turbo|\.venv|__pycache__)(\/|$)/;
6
+ // rel is '/'-separated relative to the repo root (fs.watch recursive on macOS).
7
+ // Working-tree paths count — including git-ignored ones; over-notifying is fine
8
+ // because the client refetch is cheap and fingerprint-gated. Exception: NOISE.
9
+ // Inside .git only the ref/index surface counts; objects/lock/log churn is noise.
10
+ export function isRelevant(rel) {
11
+ if (rel === '.git')
12
+ return false;
13
+ if (NOISE.test(rel))
14
+ return false;
15
+ if (!rel.startsWith('.git/'))
16
+ return true;
17
+ const inner = rel.slice('.git/'.length);
18
+ return inner === 'HEAD' || inner === 'index' || inner === 'packed-refs' || inner.startsWith('refs/');
19
+ }
20
+ // Trailing debounce with a max-wait cap: quiet bursts flush once after `delay`;
21
+ // sustained churn (long install, big rebase) still flushes every `maxWait`.
22
+ export function createDebouncer(fire, delay = 400, maxWait = 2000) {
23
+ let trailing = null;
24
+ let cap = null;
25
+ const clear = () => {
26
+ if (trailing)
27
+ clearTimeout(trailing);
28
+ if (cap)
29
+ clearTimeout(cap);
30
+ trailing = cap = null;
31
+ };
32
+ const flush = () => {
33
+ clear();
34
+ fire();
35
+ };
36
+ return {
37
+ hit() {
38
+ if (trailing)
39
+ clearTimeout(trailing);
40
+ trailing = setTimeout(flush, delay);
41
+ if (!cap)
42
+ cap = setTimeout(flush, maxWait);
43
+ },
44
+ dispose: clear,
45
+ };
46
+ }
47
+ const entries = new Map();
48
+ // test introspection: how many repos have a live watcher entry
49
+ export function activeWatcherCount() {
50
+ return entries.size;
51
+ }
52
+ // One shared recursive watcher per repo, alive only while subscribed.
53
+ export function subscribe(repo, onChange, onError) {
54
+ let entry = entries.get(repo);
55
+ if (!entry) {
56
+ // fs.watch does not agree across platforms about a path that isn't there:
57
+ // macOS and Windows reject it, but Linux — where recursive watching is layered
58
+ // over inotify — hands back a watcher that never fires and never errors. That
59
+ // silently dead subscription is worse than a failure, because the caller has no
60
+ // way to find out. Checking here makes the contract identical everywhere:
61
+ // subscribing to a missing path throws, and registers nothing.
62
+ if (!existsSync(repo)) {
63
+ throw Object.assign(new Error(`ENOENT: no such directory, watch '${repo}'`), { code: 'ENOENT' });
64
+ }
65
+ const subs = new Set();
66
+ const debouncer = createDebouncer(() => {
67
+ for (const s of subs)
68
+ s.onChange();
69
+ });
70
+ const watcher = watch(repo, { recursive: true }, (_event, filename) => {
71
+ // null filename: platform couldn't tell — refresh conservatively
72
+ if (filename === null || isRelevant(filename.toString()))
73
+ debouncer.hit();
74
+ });
75
+ const fresh = { watcher, debouncer, subs };
76
+ watcher.on('error', () => {
77
+ entries.delete(repo);
78
+ debouncer.dispose();
79
+ // The watcher may have failed before it ever started (a path that vanished, or
80
+ // never existed — Linux reports that here rather than throwing from watch()).
81
+ // Closing one in that state is not portable, and a throw inside an 'error'
82
+ // handler is an uncaught exception that takes the server down with it.
83
+ try {
84
+ watcher.close();
85
+ }
86
+ catch { /* already dead — nothing to release */ }
87
+ for (const s of [...subs])
88
+ s.onError();
89
+ subs.clear();
90
+ });
91
+ entries.set(repo, fresh);
92
+ entry = fresh;
93
+ }
94
+ const sub = { onChange, onError };
95
+ entry.subs.add(sub);
96
+ return () => {
97
+ entry.subs.delete(sub);
98
+ if (entry.subs.size === 0 && entries.get(repo) === entry) {
99
+ entry.watcher.close();
100
+ entry.debouncer.dispose();
101
+ entries.delete(repo);
102
+ }
103
+ };
104
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "megit-app",
3
+ "version": "0.1.0",
4
+ "description": "Git repository viewer in the browser: commit graph with branch lanes, diffs, stashes and WIP",
5
+ "license": "MIT",
6
+ "author": "Hoang Vuong Vu",
7
+ "homepage": "https://github.com/vuongvu1/megit#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vuongvu1/megit.git"
11
+ },
12
+ "bugs": "https://github.com/vuongvu1/megit/issues",
13
+ "keywords": [
14
+ "git",
15
+ "gui",
16
+ "commit-graph",
17
+ "diff",
18
+ "viewer"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "megit": "bin/megit.js"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "dist",
27
+ "dist-server",
28
+ "scripts/fix-pty-perms.mjs",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=24"
33
+ },
34
+ "scripts": {
35
+ "dev": "PORT=4500 UI_PORT=4000 concurrently -k \"node --watch server/index.ts\" \"vite\"",
36
+ "build": "vite build",
37
+ "build:server": "node -e \"fs.rmSync('dist-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
38
+ "start": "PORT=4500 node server/index.ts",
39
+ "test": "vitest run",
40
+ "prepublishOnly": "pnpm test && pnpm build && pnpm build:server",
41
+ "postinstall": "node scripts/fix-pty-perms.mjs"
42
+ },
43
+ "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
44
+ "dependencies": {
45
+ "express": "^5.2.1",
46
+ "ws": "^8.21.1"
47
+ },
48
+ "optionalDependencies": {
49
+ "node-pty": "^1.1.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/express": "^5.0.6",
53
+ "@types/node": "^26.1.2",
54
+ "@types/react": "^19.2.17",
55
+ "@types/react-dom": "^19.2.3",
56
+ "@types/ws": "^8.18.1",
57
+ "@vitejs/plugin-react": "^6.0.4",
58
+ "@xterm/addon-fit": "^0.11.0",
59
+ "@xterm/xterm": "^6.0.0",
60
+ "concurrently": "^10.0.4",
61
+ "diff2html": "^3.4.56",
62
+ "highlight.js": "^11.11.1",
63
+ "react": "^19.2.8",
64
+ "react-dom": "^19.2.8",
65
+ "typescript": "^7.0.2",
66
+ "vite": "^8.1.5",
67
+ "vitest": "^4.1.10"
68
+ }
69
+ }
@@ -0,0 +1,21 @@
1
+ // node-pty ships spawn-helper as a prebuilt binary, and some package managers drop
2
+ // the executable bit when extracting the tarball — the terminal then fails to spawn.
3
+ // Resolve node-pty rather than guessing ./node_modules: under `npx` it is hoisted to
4
+ // the installing project's node_modules, not ours.
5
+ import { chmodSync, readdirSync } from 'node:fs'
6
+ import { createRequire } from 'node:module'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ try {
10
+ const require = createRequire(import.meta.url)
11
+ const prebuilds = join(dirname(require.resolve('node-pty/package.json')), 'prebuilds')
12
+ for (const platform of readdirSync(prebuilds)) {
13
+ try {
14
+ chmodSync(join(prebuilds, platform, 'spawn-helper'), 0o755)
15
+ } catch {
16
+ // win32 prebuilds have no spawn-helper; nothing to do
17
+ }
18
+ }
19
+ } catch {
20
+ // node-pty is optional — absent on Linux installs without build tools
21
+ }