megit-app 0.2.0 → 0.4.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/dist/index.html CHANGED
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
10
10
  <title>megit</title>
11
- <script type="module" crossorigin src="/assets/index-BIuZ7qWL.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CEcQ7PbO.css">
11
+ <script type="module" crossorigin src="/assets/index-bXmlM6Hk.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DPM0na5E.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -5,16 +5,22 @@ const dir = join(homedir(), '.config', 'megit');
5
5
  const file = join(dir, 'config.json');
6
6
  export function loadConfig() {
7
7
  try {
8
- return JSON.parse(readFileSync(file, 'utf8'));
8
+ // Spread order matters: a config written before `recent` existed gets the default.
9
+ return { recent: [], ...JSON.parse(readFileSync(file, 'utf8')) };
9
10
  }
10
11
  catch {
11
- return { repos: [], activeRepo: null };
12
+ return { repos: [], activeRepo: null, recent: [] };
12
13
  }
13
14
  }
14
15
  export function saveConfig(c) {
15
16
  mkdirSync(dir, { recursive: true });
16
17
  writeFileSync(file, JSON.stringify(c, null, 2));
17
18
  }
19
+ // MRU: most recent first, deduped, capped. The cap is what keeps the config file
20
+ // from growing without bound as repos come and go.
21
+ export function touchRecent(recent, path, limit = 10) {
22
+ return [path, ...recent.filter(r => r !== path)].slice(0, limit);
23
+ }
18
24
  export function isPermutation(a, b) {
19
25
  if (a.length !== b.length)
20
26
  return false;
@@ -4,7 +4,7 @@ import { existsSync, readdirSync } from 'node:fs';
4
4
  import { readFile, realpath } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join, resolve, sep } from 'node:path';
7
- import { loadConfig, saveConfig, isPermutation } from './config.js';
7
+ import { loadConfig, saveConfig, isPermutation, touchRecent } from './config.js';
8
8
  import { resolveAvatar, parseGithubRemote } from './avatars.js';
9
9
  import { mergeMatches, parseBranchHeader, parseLog, parseMatches, parseMeta, parseNameStatus, parseStatus, stashIndex, LOG_FORMAT, META_FORMAT } from './parse.js';
10
10
  import { subscribe } from './watch.js';
@@ -82,6 +82,7 @@ app.post('/api/repos', async (req, res) => {
82
82
  if (!cfg.repos.includes(path))
83
83
  cfg.repos.push(path);
84
84
  cfg.activeRepo = path;
85
+ cfg.recent = touchRecent(cfg.recent, path);
85
86
  saveConfig(cfg);
86
87
  res.json(withCaps(cfg));
87
88
  });
@@ -582,9 +583,10 @@ app.post('/api/wip', repoGuard, async (req, res) => {
582
583
  break;
583
584
  }
584
585
  case 'discard':
585
- // everything back to HEAD: tracked files restored on both sides, untracked
586
- // removed. No -xignored files (node_modules, .env) are not "changes".
587
- await git(repo, ['restore', '--staged', '--worktree', '--', '.']);
586
+ // unstaged only: worktree back to the index, so staged content survives.
587
+ // clean removes untracked files a staged new file is tracked, so it stays.
588
+ // No -x ignored files (node_modules, .env) are not "changes".
589
+ await git(repo, ['restore', '--worktree', '--', '.']);
588
590
  await git(repo, ['clean', '-fd']);
589
591
  break;
590
592
  case 'stash': {
@@ -25,8 +25,18 @@ export function pushCapped(buf, size, chunk, cap = MAX_BUFFER) {
25
25
  return size;
26
26
  }
27
27
  const sessions = new Map();
28
- async function getSession(repo) {
29
- const existing = sessions.get(repo);
28
+ // Split panes are numbered 0..MAX_PANES-1 and each gets its own shell. The bound is
29
+ // enforced here, not by the client: this socket spawns login shells, so an unvalidated
30
+ // pane param is an unbounded shell factory for anything that reaches the upgrade.
31
+ export const MAX_PANES = 4;
32
+ export function termKey(repo, pane) {
33
+ const p = pane ?? '0';
34
+ if (!/^\d$/.test(p) || Number(p) >= MAX_PANES)
35
+ return null;
36
+ return `${repo}\0${p}`;
37
+ }
38
+ async function getSession(key, repo) {
39
+ const existing = sessions.get(key);
30
40
  if (existing)
31
41
  return existing;
32
42
  // dynamic import: the native module never loads until a terminal is actually opened
@@ -40,25 +50,25 @@ async function getSession(repo) {
40
50
  env: process.env,
41
51
  });
42
52
  const s = { pty, buffer: [], size: 0, clients: new Set() };
43
- sessions.set(repo, s);
53
+ sessions.set(key, s);
44
54
  pty.onData(d => {
45
55
  s.size = pushCapped(s.buffer, s.size, d);
46
56
  for (const ws of s.clients)
47
57
  ws.send(d);
48
58
  });
49
59
  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
- }
60
+ sessions.delete(key);
61
+ // 4000 tells the client "the shell is gone" (vs. a plain close, which is the
62
+ // server going away) so it can drop the pane instead of showing a reconnect hint
63
+ for (const ws of s.clients)
64
+ ws.close(4000, 'exit');
55
65
  });
56
66
  return s;
57
67
  }
58
- async function attach(repo, ws) {
68
+ async function attach(key, repo, ws) {
59
69
  let s;
60
70
  try {
61
- s = await getSession(repo);
71
+ s = await getSession(key, repo);
62
72
  }
63
73
  catch {
64
74
  // resolve() said the package is there but the native binding failed to load
@@ -85,6 +95,8 @@ async function attach(repo, ws) {
85
95
  s.buffer.length = 0;
86
96
  s.size = 0;
87
97
  }
98
+ else if (msg.t === 'k')
99
+ s.pty.kill(); // pane closed by its ✕ — onExit does the cleanup
88
100
  });
89
101
  ws.on('close', () => s.clients.delete(ws));
90
102
  }
@@ -108,6 +120,11 @@ export function wireTerminal(server) {
108
120
  socket.destroy();
109
121
  return;
110
122
  }
111
- wss.handleUpgrade(req, socket, head, ws => { void attach(repo, ws); });
123
+ const key = termKey(repo, url.searchParams.get('pane'));
124
+ if (!key) {
125
+ socket.destroy();
126
+ return;
127
+ }
128
+ wss.handleUpgrade(req, socket, head, ws => { void attach(key, repo, ws); });
112
129
  });
113
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "megit-app",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Git repository viewer in the browser: commit graph with branch lanes, diffs, stashes and WIP",
5
5
  "license": "MIT",
6
6
  "author": "Hoang Vuong Vu",
@@ -29,7 +29,7 @@
29
29
  "CHANGELOG.md"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=24"
32
+ "node": ">=22"
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "PORT=4500 UI_PORT=4000 concurrently -k \"node --watch server/index.ts\" \"vite\"",