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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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>
|