Skip to content

Commit 92c1101

Browse files
Arthur742RamosCopilotclaude
authored
feat: add copy & download buttons to web file viewer (#56)
The web demo's file-preview modal now has Copy and Download buttons, so users can grab a generated doc's contents without manually selecting text. - Copy uses the async Clipboard API, raced against a 1s timeout and backed by a document.execCommand fallback, so it never hangs and degrades gracefully. - Download saves the previewed content as a Blob named after the file. While wiring these, found that the modal's existing close button and click-outside-to-dismiss used inline `onclick` handlers that the server's Content-Security-Policy (`script-src-attr 'none'`) silently blocked — they did nothing. Moved ALL modal controls to addEventListener, which both complies with the CSP and fixes the previously-dead close/dismiss controls. Tests: templates unit tests assert the buttons, the CSP-safe wiring (no inline onclick), the clipboard-with-fallback path, and the Blob download. The Playwright web-ui spec now grants clipboard permission and exercises Copy (label flips to "Copied!") and Download (asserts the download filename). Co-authored-by: Arthur742Ramos <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6dd2c8c commit 92c1101

5 files changed

Lines changed: 165 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Web demo file viewer now has **Copy** and **Download** buttons: copy a generated doc's contents to the clipboard (async Clipboard API with a `document.execCommand` fallback and a timeout guard) or download it as a file, directly from the preview modal.
1213
- `bootcamp completion <bash|zsh|fish>` command to print a shell completion script for tab-completing subcommands, their aliases, and option flags. The completion data is derived from the live CLI definition, so it can never drift from the actual command surface; pipe it to your shell's completion directory (or `source <(bootcamp completion bash)`).
1314
- `--quiet`/`-q` flag for the main `bootcamp <repo-url>` command: suppresses the banner, run header, detected-stack table, progress spinners, score summary, and file-tree listing, printing only the output directory path on stdout so the command composes cleanly in scripts and CI (`OUT=$(bootcamp <url> --quiet)`). Failures and warnings are still written to stderr. Mutually exclusive with `--verbose`.
1415
- `bootcamp styles` command (alias `style`) to list the built-in style packs and the documentation sections each one enables, so users can choose a `--style` without reading the source. Prints a per-pack summary (tone, depth, emoji, first-task count, enabled sections) plus a section-coverage matrix, flags the default pack (`oss`), and supports `--json` for scripting.
@@ -46,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4647

4748
### Fixed
4849

50+
- Web demo modal controls (close button, click-outside-to-dismiss) were wired with inline `onclick` handlers that the server's Content-Security-Policy (`script-src-attr 'none'`) silently blocked, so they did nothing. All modal controls now use `addEventListener`, restoring close/dismiss behavior and enabling the new Copy/Download buttons under the same CSP.
4951
- `bootcamp ask`, `docs`, and `diff` now honor options whose flag names collide with the root command. `ask --branch/--model`, `docs --branch`, and `diff --format/--full-clone/--keep-temp` were captured by the root command and silently ignored; they are now read from raw argv (via shared, tested `getFlagValue`/`hasFlag` helpers in `src/utils.ts`).
5052
- `bootcamp health` now honors `--branch` and `--max-files`. These short flags (`-b`/`-m`) collide with the root command's options, which captured them before the subcommand could; the command now falls back to reading raw argv (matching the `diff --output` approach). The `--json` output also gained a `filesScanned` field.
5153
- Reused shared prompt helper builders in `src/agent.ts` for standard/fast prompt construction.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,8 @@ bootcamp web --port 8080
514514
# Then open http://localhost:3000 in your browser
515515
```
516516

517+
The browser UI streams live progress, then lets you preview each generated file in a modal with one-click **Copy** (to clipboard) and **Download** buttons.
518+
517519
![Web Dashboard](media/screenshot-web-dashboard.png)
518520

519521
The web interface allows you to analyze repositories interactively through your browser.

src/web/templates.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,35 @@ export function getIndexHtml(): string {
127127
border-radius: 8px;
128128
padding: 2rem;
129129
}
130-
.modal-header {
131-
display: flex;
132-
justify-content: space-between;
130+
.modal-header {
131+
display: flex;
132+
justify-content: space-between;
133133
align-items: center;
134134
margin-bottom: 1rem;
135+
gap: 1rem;
135136
}
136-
.close {
137-
background: none;
138-
border: none;
139-
color: #888;
140-
font-size: 2rem;
137+
.modal-actions { display: flex; gap: 0.5rem; align-items: center; }
138+
.icon-btn {
139+
padding: 0.4rem 0.85rem;
140+
border: 1px solid #30363d;
141+
border-radius: 6px;
142+
background: #161b22;
143+
color: #e0e0e0;
144+
font-size: 0.85rem;
145+
font-weight: 600;
146+
cursor: pointer;
147+
transition: background 0.15s, border-color 0.15s, transform 0.15s;
148+
}
149+
.icon-btn:hover { background: #21262d; border-color: #00d9ff; transform: none; }
150+
.icon-btn:active { transform: scale(0.97); }
151+
.icon-btn.copied { border-color: #00ff88; color: #00ff88; }
152+
.close {
153+
background: none;
154+
border: none;
155+
color: #888;
156+
font-size: 2rem;
141157
cursor: pointer;
158+
line-height: 1;
142159
}
143160
.close:hover { color: #fff; }
144161
pre {
@@ -179,19 +196,23 @@ export function getIndexHtml(): string {
179196
role="dialog"
180197
aria-modal="true"
181198
aria-labelledby="modalTitle"
182-
onclick="if(event.target===this)closeModal()"
183199
>
184200
<div class="modal-content">
185201
<div class="modal-header">
186202
<h2 id="modalTitle"></h2>
187-
<button class="close" type="button" aria-label="Close file preview" onclick="closeModal()">&times;</button>
203+
<div class="modal-actions">
204+
<button class="icon-btn" type="button" id="copyBtn">Copy</button>
205+
<button class="icon-btn" type="button" id="downloadBtn">Download</button>
206+
<button class="close" type="button" id="closeBtn" aria-label="Close file preview">&times;</button>
207+
</div>
188208
</div>
189209
<pre id="modalContent"></pre>
190210
</div>
191211
</div>
192212
193213
<script>
194214
let currentJobId = null;
215+
let currentFile = null;
195216
196217
const fileDescriptions = {
197218
'BOOTCAMP': 'One-page overview',
@@ -342,11 +363,69 @@ export function getIndexHtml(): string {
342363
343364
async function viewFile(filename) {
344365
const content = await fetch('/api/jobs/' + currentJobId + '/files/' + encodeURIComponent(filename)).then(r => r.text());
366+
currentFile = { name: filename, content };
345367
document.getElementById('modalTitle').textContent = filename;
346368
document.getElementById('modalContent').textContent = content;
369+
const copyBtn = document.getElementById('copyBtn');
370+
copyBtn.textContent = 'Copy';
371+
copyBtn.classList.remove('copied');
347372
document.getElementById('modal').classList.add('show');
348373
}
349374
375+
function legacyCopy(text) {
376+
const ta = document.createElement('textarea');
377+
ta.value = text;
378+
ta.style.position = 'fixed';
379+
ta.style.opacity = '0';
380+
document.body.appendChild(ta);
381+
ta.select();
382+
let ok = false;
383+
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
384+
document.body.removeChild(ta);
385+
return ok;
386+
}
387+
388+
async function copyFile() {
389+
if (!currentFile) return;
390+
const btn = document.getElementById('copyBtn');
391+
let copied = false;
392+
try {
393+
if (navigator.clipboard && navigator.clipboard.writeText) {
394+
// The async clipboard API can hang or be denied in some browsers;
395+
// race it against a short timeout and fall back to execCommand.
396+
await Promise.race([
397+
navigator.clipboard.writeText(currentFile.content),
398+
new Promise((_, reject) => setTimeout(() => reject(new Error('clipboard timeout')), 1000)),
399+
]);
400+
copied = true;
401+
} else {
402+
copied = legacyCopy(currentFile.content);
403+
}
404+
} catch (err) {
405+
copied = legacyCopy(currentFile.content);
406+
}
407+
408+
btn.textContent = copied ? 'Copied!' : 'Copy failed';
409+
btn.classList.toggle('copied', copied);
410+
setTimeout(() => {
411+
btn.textContent = 'Copy';
412+
btn.classList.remove('copied');
413+
}, 1500);
414+
}
415+
416+
function downloadFile() {
417+
if (!currentFile) return;
418+
const blob = new Blob([currentFile.content], { type: 'text/plain;charset=utf-8' });
419+
const url = URL.createObjectURL(blob);
420+
const a = document.createElement('a');
421+
a.href = url;
422+
a.download = currentFile.name;
423+
document.body.appendChild(a);
424+
a.click();
425+
document.body.removeChild(a);
426+
URL.revokeObjectURL(url);
427+
}
428+
350429
function closeModal() {
351430
document.getElementById('modal').classList.remove('show');
352431
}
@@ -362,6 +441,15 @@ export function getIndexHtml(): string {
362441
void analyze();
363442
});
364443
444+
// Modal controls are wired here (not via inline onclick) to comply with the
445+
// server's Content-Security-Policy, which blocks inline event handlers.
446+
document.getElementById('copyBtn').addEventListener('click', () => { void copyFile(); });
447+
document.getElementById('downloadBtn').addEventListener('click', downloadFile);
448+
document.getElementById('closeBtn').addEventListener('click', closeModal);
449+
document.getElementById('modal').addEventListener('click', (event) => {
450+
if (event.target === document.getElementById('modal')) closeModal();
451+
});
452+
365453
document.addEventListener('keydown', (e) => {
366454
if (e.key === 'Escape') closeModal();
367455
});

test/playwright/web-ui.e2e.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ test.describe("web UI", () => {
182182
tempDirs.length = 0;
183183
});
184184

185-
test("submits analysis, streams progress, and opens generated files in the browser", async ({ page }) => {
185+
test("submits analysis, streams progress, and opens generated files in the browser", async ({ page, context }) => {
186+
// The modal Copy button uses navigator.clipboard.writeText; grant the
187+
// permission up front so the primary path is exercised (a fallback exists).
188+
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
189+
186190
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-web-ui-e2e-"));
187191
tempDirs.push(tempDir);
188192

@@ -243,6 +247,21 @@ test.describe("web UI", () => {
243247
await expect(page.locator("#modalTitle")).toHaveText("BOOTCAMP.md");
244248
await expect(page.locator("#modalContent")).toContainText("fixture-web-ui-repo");
245249

250+
// Copy button: clicking runs the copy handler and shows transient
251+
// confirmation. The "Copied!" label resets after a short timer, so poll for
252+
// it (rather than a single check) to avoid racing the reset.
253+
await expect(page.locator("#copyBtn")).toHaveText("Copy");
254+
await page.locator("#copyBtn").click();
255+
await expect(page.locator("#copyBtn")).toHaveText("Copied!");
256+
// ...and it returns to the idle label afterwards.
257+
await expect(page.locator("#copyBtn")).toHaveText("Copy");
258+
259+
// Download button: triggers a browser download named after the file.
260+
const downloadPromise = page.waitForEvent("download");
261+
await page.locator("#downloadBtn").click();
262+
const download = await downloadPromise;
263+
expect(download.suggestedFilename()).toBe("BOOTCAMP.md");
264+
246265
await page.keyboard.press("Escape");
247266
await expect(page.locator("#modal")).toBeHidden();
248267

test/templates.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ describe("getIndexHtml", () => {
6969
expect(html).toContain('id="modalTitle"');
7070
expect(html).toContain('id="modalContent"');
7171
});
72+
73+
it("has copy and download buttons in the modal", () => {
74+
const html = getIndexHtml();
75+
expect(html).toContain('id="copyBtn"');
76+
expect(html).toContain('id="downloadBtn"');
77+
expect(html).toContain('id="closeBtn"');
78+
});
79+
80+
it("wires modal controls via addEventListener (no inline onclick, for CSP)", () => {
81+
const html = getIndexHtml();
82+
// The server sets a CSP that blocks inline event handlers, so the UI must
83+
// not rely on onclick attributes anywhere.
84+
expect(html).not.toContain("onclick=");
85+
expect(html).toContain("getElementById('copyBtn').addEventListener('click'");
86+
expect(html).toContain("getElementById('downloadBtn').addEventListener('click'");
87+
expect(html).toContain("getElementById('closeBtn').addEventListener('click'");
88+
});
7289
});
7390

7491
describe("inline CSS", () => {
@@ -129,6 +146,32 @@ describe("getIndexHtml", () => {
129146
expect(html).toContain("function closeModal()");
130147
});
131148

149+
it("defines the copyFile and downloadFile functions", () => {
150+
const html = getIndexHtml();
151+
expect(html).toContain("async function copyFile()");
152+
expect(html).toContain("function downloadFile()");
153+
});
154+
155+
it("uses the clipboard API with a fallback for copy", () => {
156+
const html = getIndexHtml();
157+
expect(html).toContain("navigator.clipboard");
158+
expect(html).toContain("function legacyCopy(");
159+
expect(html).toContain("document.execCommand('copy')");
160+
});
161+
162+
it("races the async clipboard write against a timeout", () => {
163+
const html = getIndexHtml();
164+
expect(html).toContain("Promise.race");
165+
expect(html).toContain("clipboard timeout");
166+
});
167+
168+
it("creates a Blob download with the file name", () => {
169+
const html = getIndexHtml();
170+
expect(html).toContain("new Blob(");
171+
expect(html).toContain("a.download = currentFile.name");
172+
expect(html).toContain("URL.revokeObjectURL");
173+
});
174+
132175
it("handles Escape key to close modal", () => {
133176
const html = getIndexHtml();
134177
expect(html).toContain("Escape");

0 commit comments

Comments
 (0)