lemming-cli 0.1.0__py3-none-any.whl

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.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/web/favicon.js ADDED
@@ -0,0 +1,40 @@
1
+ window.updateFavicon = (state) => {
2
+ const emoji = '🐹';
3
+ let overlay = '';
4
+
5
+ // Badge position: Bottom-Right (75, 75)
6
+ const bx = 75;
7
+ const by = 75;
8
+
9
+ if (state === 'running') {
10
+ overlay = `
11
+ <circle cx="${bx}" cy="${by}" r="30" fill="white" />
12
+ <circle cx="${bx}" cy="${by}" r="22" fill="#1e40af">
13
+ <animate attributeName="r" values="16;26;16" dur="1s" repeatCount="indefinite" />
14
+ <animate attributeName="fill-opacity" values="0.6;1;0.6" dur="1s" repeatCount="indefinite" />
15
+ </circle>
16
+ `;
17
+ } else if (state === 'error') {
18
+ overlay = `
19
+ <circle cx="${bx}" cy="${by}" r="30" fill="white" />
20
+ <circle cx="${bx}" cy="${by}" r="24" fill="#9f1239" />
21
+ <path d="M${bx - 10} ${by - 10} l20 20 M${bx + 10} ${by - 10} l-20 20" stroke="white" stroke-width="10" stroke-linecap="round" />
22
+ `;
23
+ } else if (state === 'success') {
24
+ overlay = `
25
+ <circle cx="${bx}" cy="${by}" r="30" fill="white" />
26
+ <circle cx="${bx}" cy="${by}" r="24" fill="#065f46" />
27
+ <path d="M${bx - 12} ${by} l8 8 l16 -16" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none" />
28
+ `;
29
+ }
30
+
31
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
32
+ <text y=".9em" font-size="90">${emoji}</text>
33
+ ${overlay}
34
+ </svg>`;
35
+
36
+ const link = document.querySelector('link[rel="icon"]');
37
+ if (link) {
38
+ link.href = `data:image/svg+xml,${encodeURIComponent(svg)}`;
39
+ }
40
+ };
@@ -0,0 +1,242 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { expect, test } from '@playwright/test';
4
+
5
+ const indexHtmlPath = path.resolve(process.cwd(), 'src/lemming/web/index.html');
6
+ const indexJsPath = path.resolve(process.cwd(), 'src/lemming/web/index.js');
7
+ const filesHtmlPath = path.resolve(process.cwd(), 'src/lemming/web/files.html');
8
+ const logsHtmlPath = path.resolve(process.cwd(), 'src/lemming/web/logs.html');
9
+ const manchaJsPath = path.resolve(process.cwd(), 'src/lemming/web/mancha.js');
10
+ const faviconJsPath = path.resolve(process.cwd(), 'src/lemming/web/favicon.js');
11
+
12
+ test.describe('Favicon Status Synchronization', () => {
13
+ test.beforeEach(async ({ page }) => {
14
+ // Serve static files via mocks
15
+ await page.route('**/static/mancha.js', async (route) => {
16
+ await route.fulfill({
17
+ contentType: 'application/javascript',
18
+ body: fs.readFileSync(manchaJsPath, 'utf8'),
19
+ });
20
+ });
21
+ await page.route('**/static/favicon.js', async (route) => {
22
+ await route.fulfill({
23
+ contentType: 'application/javascript',
24
+ body: fs.readFileSync(faviconJsPath, 'utf8'),
25
+ });
26
+ });
27
+ await page.route('**/static/index.js', async (route) => {
28
+ await route.fulfill({
29
+ contentType: 'application/javascript',
30
+ body: fs.readFileSync(indexJsPath, 'utf8'),
31
+ });
32
+ });
33
+ });
34
+
35
+ test('Dashboard (index.html) favicon status and seen state', async ({
36
+ page,
37
+ }) => {
38
+ let loopRunning = false;
39
+ let tasks = [];
40
+
41
+ await page.route('**/api/data**', async (route) => {
42
+ await route.fulfill({
43
+ contentType: 'application/json',
44
+ json: { loop_running: loopRunning, tasks: tasks, context: '', cwd: '' },
45
+ });
46
+ });
47
+ await page.route('**/api/runners', async (route) => {
48
+ await route.fulfill({
49
+ contentType: 'application/json',
50
+ json: ['agy', 'aider'],
51
+ });
52
+ });
53
+
54
+ await page.route('**/api/hooks', async (route) => {
55
+ await route.fulfill({
56
+ contentType: 'application/json',
57
+ json: ['roadmap'],
58
+ });
59
+ });
60
+
61
+ await page.route('http://localhost:8000/', async (route) => {
62
+ await route.fulfill({
63
+ contentType: 'text/html',
64
+ body: fs.readFileSync(indexHtmlPath, 'utf8'),
65
+ });
66
+ });
67
+
68
+ await page.goto('http://localhost:8000/');
69
+ await page.waitForFunction(() => window.ManchaApp !== undefined);
70
+ await page.evaluate(async () => await window.ManchaApp);
71
+
72
+ // Initial idle
73
+ await expect(async () => {
74
+ const href = await page.evaluate(
75
+ () => document.querySelector('link[rel="icon"]').href,
76
+ );
77
+ const svg = decodeURIComponent(href);
78
+ expect(svg).toContain('🐹');
79
+ expect(svg).not.toContain('circle');
80
+ }).toPass();
81
+
82
+ // Running
83
+ loopRunning = true;
84
+ tasks = [{ id: 't1', status: 'in_progress', attempts: 1 }];
85
+ await expect(async () => {
86
+ const href = await page.evaluate(
87
+ () => document.querySelector('link[rel="icon"]').href,
88
+ );
89
+ const svg = decodeURIComponent(href);
90
+ expect(svg).toContain('circle');
91
+ expect(svg).toContain('animate');
92
+ }).toPass();
93
+
94
+ // Success
95
+ loopRunning = false;
96
+ tasks = [{ id: 't1', status: 'completed', attempts: 1 }];
97
+ await expect(async () => {
98
+ const href = await page.evaluate(
99
+ () => document.querySelector('link[rel="icon"]').href,
100
+ );
101
+ const svg = decodeURIComponent(href);
102
+ expect(svg).toContain('circle');
103
+ expect(svg).toContain('#065f46'); // green
104
+ }).toPass();
105
+
106
+ // Mark as seen
107
+ await page.evaluate(() => {
108
+ Object.defineProperty(document, 'visibilityState', {
109
+ value: 'visible',
110
+ writable: true,
111
+ });
112
+ document.dispatchEvent(new Event('visibilitychange'));
113
+ });
114
+
115
+ // Reload shows idle
116
+ await page.reload();
117
+ await page.waitForFunction(() => window.ManchaApp !== undefined);
118
+ await page.evaluate(async () => await window.ManchaApp);
119
+ await expect(async () => {
120
+ const href = await page.evaluate(
121
+ () => document.querySelector('link[rel="icon"]').href,
122
+ );
123
+ const svg = decodeURIComponent(href);
124
+ expect(svg).not.toContain('circle');
125
+ }).toPass();
126
+ });
127
+
128
+ test('Files (files.html) favicon status reflects project status', async ({
129
+ page,
130
+ }) => {
131
+ let loopRunning = true;
132
+ let tasks = [{ id: 't1', status: 'in_progress', attempts: 1 }];
133
+
134
+ await page.route('**/api/data**', async (route) => {
135
+ await route.fulfill({
136
+ contentType: 'application/json',
137
+ json: { loop_running: loopRunning, tasks: tasks, context: '', cwd: '' },
138
+ });
139
+ });
140
+ await page.route('**/api/files/**', async (route) => {
141
+ await route.fulfill({
142
+ contentType: 'application/json',
143
+ json: { path: '', contents: [] },
144
+ });
145
+ });
146
+ await page.route('http://localhost:8000/files/', async (route) => {
147
+ await route.fulfill({
148
+ contentType: 'text/html',
149
+ body: fs.readFileSync(filesHtmlPath, 'utf8'),
150
+ });
151
+ });
152
+
153
+ await page.goto('http://localhost:8000/files/');
154
+ await page.waitForFunction(() => window.ManchaApp !== undefined);
155
+ await page.evaluate(async () => await window.ManchaApp);
156
+
157
+ await expect(async () => {
158
+ const href = await page.evaluate(
159
+ () => document.querySelector('link[rel="icon"]').href,
160
+ );
161
+ const svg = decodeURIComponent(href);
162
+ expect(svg).toContain('circle'); // running
163
+ }).toPass();
164
+
165
+ // Error
166
+ loopRunning = false;
167
+ tasks = [{ id: 't1', status: 'pending', attempts: 1 }];
168
+ await expect(async () => {
169
+ const href = await page.evaluate(
170
+ () => document.querySelector('link[rel="icon"]').href,
171
+ );
172
+ const svg = decodeURIComponent(href);
173
+ expect(svg).toContain('#9f1239'); // red
174
+ }).toPass();
175
+ });
176
+
177
+ test('Logs (logs.html) favicon status reflects task status', async ({
178
+ page,
179
+ }) => {
180
+ let taskStatus = 'in_progress';
181
+ const attempts = 1;
182
+
183
+ await page.route('**/api/tasks/t1', async (route) => {
184
+ await route.fulfill({
185
+ contentType: 'application/json',
186
+ json: {
187
+ id: 't1',
188
+ status: taskStatus,
189
+ attempts: attempts,
190
+ description: 'Task 1',
191
+ },
192
+ });
193
+ });
194
+ await page.route('**/api/tasks/t1/log**', async (route) => {
195
+ await route.fulfill({
196
+ contentType: 'application/json',
197
+ json: { log: 'test log' },
198
+ });
199
+ });
200
+ await page.route('http://localhost:8000/tasks/t1/log', async (route) => {
201
+ await route.fulfill({
202
+ contentType: 'text/html',
203
+ body: fs.readFileSync(logsHtmlPath, 'utf8'),
204
+ });
205
+ });
206
+
207
+ await page.goto('http://localhost:8000/tasks/t1/log');
208
+ await page.waitForFunction(() => window.ManchaApp !== undefined);
209
+ await page.evaluate(async () => await window.ManchaApp);
210
+
211
+ await expect(async () => {
212
+ const href = await page.evaluate(
213
+ () => document.querySelector('link[rel="icon"]').href,
214
+ );
215
+ const svg = decodeURIComponent(href);
216
+ expect(svg).toContain('circle'); // running
217
+ }).toPass();
218
+
219
+ // Success
220
+ taskStatus = 'completed';
221
+ await expect(async () => {
222
+ const href = await page.evaluate(
223
+ () => document.querySelector('link[rel="icon"]').href,
224
+ );
225
+ const svg = decodeURIComponent(href);
226
+ expect(svg).toContain('#065f46'); // green
227
+ }).toPass();
228
+
229
+ // Mark as seen
230
+ await page.evaluate(() => {
231
+ Object.defineProperty(document, 'visibilityState', {
232
+ value: 'visible',
233
+ writable: true,
234
+ });
235
+ document.dispatchEvent(new Event('visibilitychange'));
236
+ });
237
+ const lastSeen = await page.evaluate(() =>
238
+ localStorage.getItem('lemming[]_last_seen_state'),
239
+ );
240
+ expect(lastSeen).toBe(JSON.stringify('success'));
241
+ });
242
+ });
lemming/web/files.html ADDED
@@ -0,0 +1,375 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Lemming</title>
7
+ <link
8
+ rel="icon"
9
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐹</text></svg>"
10
+ />
11
+ <style id="mancha-cloak">
12
+ html {
13
+ background-color: #f9fafb; /* Gray 50 */
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ html {
17
+ background-color: #1f2937; /* Gray 800 */
18
+ }
19
+ }
20
+ body {
21
+ opacity: 0 !important;
22
+ }
23
+ </style>
24
+ <style>
25
+ /* Loading indicator visible during cloaking */
26
+ html.mancha-loading::after {
27
+ content: 'Loading Files...';
28
+ position: fixed;
29
+ inset: 0;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ font-family: ui-sans-serif, system-ui, sans-serif;
34
+ font-size: 1.25rem;
35
+ font-weight: 600;
36
+ color: #4f46e5; /* Indigo 600 */
37
+ z-index: 9999;
38
+ animation: mancha-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
39
+ }
40
+
41
+ @media (prefers-color-scheme: dark) {
42
+ html.mancha-loading::after {
43
+ color: #818cf8; /* Indigo 400 */
44
+ }
45
+ }
46
+
47
+ @keyframes mancha-pulse {
48
+ 0%,
49
+ 100% {
50
+ opacity: 1;
51
+ }
52
+ 50% {
53
+ opacity: 0.5;
54
+ }
55
+ }
56
+ </style>
57
+ <script src="/static/mancha.js" css="utils"></script>
58
+ <script src="/static/favicon.js"></script>
59
+ </head>
60
+ <body
61
+ class="bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-sans min-h-screen flex flex-col"
62
+ >
63
+ <div class="max-w-4xl mx-auto p-4 flex-grow w-full flex flex-col">
64
+ <div :show="loading" class="flex-grow flex items-center justify-center">
65
+ <span class="text-gray-400 text-sm animate-pulse">Loading...</span>
66
+ </div>
67
+ <header
68
+ :show="!loading"
69
+ class="mb-4 flex items-center justify-between gap-4 shrink-0"
70
+ >
71
+ <div class="flex items-center gap-4 shrink-0">
72
+ <a
73
+ :attr:href="'/' + ($$project ? '?project=' + encodeURIComponent($$project) : '')"
74
+ class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition duration-200"
75
+ title="Back to Dashboard"
76
+ aria-label="Back to Dashboard"
77
+ >
78
+ <svg
79
+ xmlns="http://www.w3.org/2000/svg"
80
+ class="h-6 w-6"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ stroke="currentColor"
84
+ >
85
+ <path
86
+ stroke-linecap="round"
87
+ stroke-linejoin="round"
88
+ stroke-width="2"
89
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
90
+ />
91
+ </svg>
92
+ </a>
93
+ <h1
94
+ class="text-2xl font-bold text-indigo-600 dark:text-indigo-400"
95
+ title="Lemming Task Runner"
96
+ >
97
+ Lemming Task Runner
98
+ </h1>
99
+ </div>
100
+ <div
101
+ class="text-sm font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded truncate min-w-0"
102
+ :attr:title="'/' + path"
103
+ >
104
+ /{{ path }}
105
+ </div>
106
+ </header>
107
+
108
+ <section
109
+ :show="!loading"
110
+ class="bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-600 overflow-hidden"
111
+ >
112
+ <div class="overflow-x-auto">
113
+ <table class="w-full text-left border-collapse">
114
+ <thead
115
+ class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-600"
116
+ >
117
+ <tr>
118
+ <th
119
+ class="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider"
120
+ >
121
+ Name
122
+ </th>
123
+ <th
124
+ class="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider w-32"
125
+ >
126
+ Size
127
+ </th>
128
+ <th
129
+ class="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider w-48"
130
+ >
131
+ Last Modified
132
+ </th>
133
+ </tr>
134
+ </thead>
135
+ <tbody class="bg-white dark:bg-gray-900">
136
+ <tr
137
+ :if="path"
138
+ class="border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
139
+ >
140
+ <td class="px-4 py-2" colspan="3">
141
+ <a
142
+ :attr:href="'/files/' + path.split('/').slice(0, -1).join('/')"
143
+ class="text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-2"
144
+ >
145
+ <svg
146
+ xmlns="http://www.w3.org/2000/svg"
147
+ class="h-4 w-4"
148
+ fill="none"
149
+ viewBox="0 0 24 24"
150
+ stroke="currentColor"
151
+ >
152
+ <path
153
+ stroke-linecap="round"
154
+ stroke-linejoin="round"
155
+ stroke-width="2"
156
+ d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
157
+ />
158
+ </svg>
159
+ .. / Parent Directory
160
+ </a>
161
+ </td>
162
+ </tr>
163
+ <tr
164
+ :for="item in contents"
165
+ :key="item.path"
166
+ class="border-b border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
167
+ >
168
+ <td class="px-4 py-2">
169
+ <a
170
+ :attr:href="'/files/' + item.path"
171
+ :attr:class="'flex items-center gap-2 ' + (item.is_dir ? 'text-indigo-600 font-medium' : 'text-gray-700 dark:text-gray-300')"
172
+ :attr:target="item.is_dir ? '' : '_blank'"
173
+ >
174
+ <svg
175
+ :if="item.is_dir"
176
+ xmlns="http://www.w3.org/2000/svg"
177
+ class="h-4 w-4 text-indigo-400"
178
+ fill="none"
179
+ viewBox="0 0 24 24"
180
+ stroke="currentColor"
181
+ >
182
+ <path
183
+ stroke-linecap="round"
184
+ stroke-linejoin="round"
185
+ stroke-width="2"
186
+ d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
187
+ />
188
+ </svg>
189
+ <svg
190
+ :if="!item.is_dir"
191
+ xmlns="http://www.w3.org/2000/svg"
192
+ class="h-4 w-4 text-gray-400"
193
+ fill="none"
194
+ viewBox="0 0 24 24"
195
+ stroke="currentColor"
196
+ >
197
+ <path
198
+ stroke-linecap="round"
199
+ stroke-linejoin="round"
200
+ stroke-width="2"
201
+ d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
202
+ />
203
+ </svg>
204
+ {{ item.name }}
205
+ </a>
206
+ </td>
207
+ <td class="px-4 py-2 text-sm text-gray-500 font-mono">
208
+ {{ formatSize(item.size) }}
209
+ </td>
210
+ <td class="px-4 py-2 text-sm text-gray-500">
211
+ {{ formatDate(item.modified) }}
212
+ </td>
213
+ </tr>
214
+ <tr :if="!loading && contents.length === 0">
215
+ <td
216
+ class="px-4 py-8 text-center text-gray-500 text-sm"
217
+ colspan="3"
218
+ >
219
+ This directory is empty.
220
+ </td>
221
+ </tr>
222
+ </tbody>
223
+ </table>
224
+ </div>
225
+ </section>
226
+ </div>
227
+
228
+ <script>
229
+ (async () => {
230
+ window.ManchaApp = Mancha.initMancha({
231
+ cloak: true,
232
+ callback: async (renderer) => {
233
+ const { $ } = renderer;
234
+
235
+ $.path = '';
236
+ $.contents = [];
237
+ $.loading = true;
238
+
239
+ // --- Project Scoping ---
240
+ // $$project is auto-synced with the ?project= URL query param by mancha.
241
+ $.$$project = $.$$project ?? '';
242
+
243
+ // --- Persistence Management (scoped by project) ---
244
+ const Storage = {
245
+ getPrefix() {
246
+ return `lemming[${$.$$project}]_`;
247
+ },
248
+ get(key, fallback) {
249
+ try {
250
+ const val = localStorage.getItem(this.getPrefix() + key);
251
+ return val !== null ? JSON.parse(val) : fallback;
252
+ } catch {
253
+ return fallback;
254
+ }
255
+ },
256
+ set(key, val) {
257
+ localStorage.setItem(
258
+ this.getPrefix() + key,
259
+ JSON.stringify(val),
260
+ );
261
+ },
262
+ };
263
+
264
+ $.faviconState = 'idle';
265
+ $.lastSeenState = null; // Hydrate after mount
266
+
267
+ $.formatSize = (bytes) => {
268
+ if (bytes === null || bytes === undefined) return '';
269
+ if (bytes === 0) return '0 B';
270
+ const k = 1024;
271
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
272
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
273
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
274
+ };
275
+
276
+ $.formatDate = (ts) => {
277
+ return ts ? new Date(ts * 1000).toLocaleString() : '';
278
+ };
279
+
280
+ $.fetchFiles = async () => {
281
+ // Extract path from URL: /files/some/path -> some/path
282
+ const pathname = window.location.pathname;
283
+ const path = pathname
284
+ .replace(/^\/files\/?/, '')
285
+ .replace(/\/$/, '');
286
+
287
+ const [filesRes, dataRes] = await Promise.all([
288
+ fetch(`/api/files/${path}`),
289
+ fetch(
290
+ $.$$project
291
+ ? `/api/data?project=${encodeURIComponent($.$$project)}`
292
+ : '/api/data',
293
+ ),
294
+ ]);
295
+
296
+ if (filesRes.ok) {
297
+ const data = await filesRes.json();
298
+ $.path = data.path;
299
+ $.contents = data.contents;
300
+
301
+ // Update title: Use the top-most folder from the current path or project.
302
+ const currentPath = $.path || $.$$project || '';
303
+ const folderName = currentPath.split('/').filter(Boolean)[0];
304
+ if (folderName) {
305
+ document.title = `Lemming · ${folderName}`;
306
+ } else {
307
+ document.title = 'Lemming';
308
+ }
309
+ } else {
310
+ console.error('Failed to fetch files');
311
+ }
312
+
313
+ if (dataRes.ok) {
314
+ const data = await dataRes.json();
315
+ const hasError = data.tasks.some(
316
+ (t) => t.status === 'pending' && t.attempts > 0,
317
+ );
318
+ const allCompleted =
319
+ data.tasks.length > 0 &&
320
+ data.tasks.every((t) => t.status === 'completed');
321
+ const state = data.loop_running
322
+ ? 'running'
323
+ : hasError
324
+ ? 'error'
325
+ : allCompleted
326
+ ? 'success'
327
+ : 'idle';
328
+
329
+ $.faviconState = state;
330
+
331
+ // If a run starts, reset the last seen state
332
+ if (state === 'running') {
333
+ $.lastSeenState = null;
334
+ Storage.set('last_seen_state', null);
335
+ }
336
+
337
+ // If the current terminal state has already been seen by the user, show 'idle' favicon instead.
338
+ const effectiveState =
339
+ (state === 'success' || state === 'error') &&
340
+ state === $.lastSeenState
341
+ ? 'idle'
342
+ : state;
343
+ if (window.updateFavicon) window.updateFavicon(effectiveState);
344
+ }
345
+
346
+ $.loading = false;
347
+ };
348
+
349
+ document.addEventListener('visibilitychange', () => {
350
+ if (document.visibilityState === 'visible') {
351
+ // Force an immediate fetch when returning to the tab.
352
+ $.fetchFiles();
353
+
354
+ const state = $.faviconState;
355
+ if (state === 'success' || state === 'error') {
356
+ $.lastSeenState = state;
357
+ Storage.set('last_seen_state', state);
358
+ if (window.updateFavicon) window.updateFavicon('idle');
359
+ }
360
+ }
361
+ });
362
+
363
+ await renderer.mount(document.body);
364
+
365
+ // Hydrate persistence after mount
366
+ $.lastSeenState = Storage.get('last_seen_state', null);
367
+
368
+ await $.fetchFiles();
369
+ setInterval(() => $.fetchFiles(), 1000);
370
+ },
371
+ });
372
+ })();
373
+ </script>
374
+ </body>
375
+ </html>