diff --git a/backend/go.mod b/backend/go.mod index 55657904d..5c7f80fb6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/swaggest/jsonschema-go v0.3.79 github.com/swaggest/openapi-go v0.2.61 + github.com/yuin/goldmark v1.8.2 golang.org/x/sys v0.44.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.51.0 diff --git a/backend/go.sum b/backend/go.sum index a7e9d8331..475afdf5f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -68,6 +68,8 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 348f65ff1..8b61fe391 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -189,9 +189,32 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) return } + if previewutil.IsMarkdownPath(file) { + c.servePreviewMarkdown(w, r, file) + return + } http.ServeFile(w, r, file) } +// servePreviewMarkdown renders a workspace Markdown file to a self-contained +// HTML document so the browser panel displays formatted content instead of raw +// source. +func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) { + source, err := os.ReadFile(file) + if err != nil { + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil) + return + } + rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file)) + if err != nil { + envelope.WriteError(w, r, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted +} + // setPreview persists the browser preview URL the desktop app opens for a // session and fans out a session_updated CDC event so the dashboard's browser // panel reacts live. The target is resolved as follows: diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go index 2cc3a60c6..0b2a2bbf5 100644 --- a/backend/internal/preview/entry.go +++ b/backend/internal/preview/entry.go @@ -1,6 +1,7 @@ package preview import ( + "io/fs" "net/url" "os" "path" @@ -13,6 +14,19 @@ import ( var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} +// previewableExts are the file extensions the browser panel can render: HTML +// verbatim and Markdown converted to HTML by the preview/files route. +var previewableExts = map[string]struct{}{ + ".html": {}, + ".htm": {}, + ".md": {}, + ".markdown": {}, +} + +// maxPreviewWalkFiles bounds the most-recent fallback scan so a pathological +// workspace cannot stall the preview poller. +const maxPreviewWalkFiles = 5000 + // Entry is a workspace-local static frontend entrypoint. type Entry struct { Path string @@ -21,8 +35,11 @@ type Entry struct { Size int64 } -// DiscoverEntry returns the first supported HTML entrypoint that exists inside -// the workspace. +// DiscoverEntry returns the entry the browser panel should preview for a +// workspace. A conventional index.html (or its public/dist/build variants) +// always wins; when none exists it falls back to the most-recently-modified +// previewable file (.html/.htm/.md/.markdown) anywhere in the workspace, so a +// freshly generated report or document shows up automatically. func DiscoverEntry(workspacePath string) (Entry, bool) { if strings.TrimSpace(workspacePath) == "" { return Entry{}, false @@ -37,7 +54,82 @@ func DiscoverEntry(workspacePath string) (Entry, bool) { return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true } } - return Entry{}, false + return mostRecentPreviewable(workspacePath) +} + +// mostRecentPreviewable walks the workspace and returns the newest previewable +// file. Ties (equal mod times) break on the slash path so the result is +// deterministic. Hidden directories and node_modules are skipped, and the scan +// is bounded by maxPreviewWalkFiles. +func mostRecentPreviewable(workspacePath string) (Entry, bool) { + root, err := filepath.Abs(workspacePath) + if err != nil { + return Entry{}, false + } + var best Entry + found := false + seen := 0 + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + //nolint:nilerr // skip unreadable entries rather than aborting the whole scan + return nil + } + if d.IsDir() { + if p != root && skipPreviewDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + if _, ok := previewableExts[strings.ToLower(filepath.Ext(d.Name()))]; !ok { + return nil + } + seen++ + if seen > maxPreviewWalkFiles { + return filepath.SkipAll + } + info, err := d.Info() + if err != nil { + //nolint:nilerr // skip this file, keep scanning the rest of the workspace + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + //nolint:nilerr // skip this file, keep scanning the rest of the workspace + return nil + } + relSlash := filepath.ToSlash(rel) + if !found || newerPreviewable(info, relSlash, best) { + best = Entry{Path: relSlash, AbsPath: p, ModTime: info.ModTime(), Size: info.Size()} + found = true + } + return nil + }) + return best, found +} + +func newerPreviewable(info fs.FileInfo, relSlash string, best Entry) bool { + mod := info.ModTime() + if mod.After(best.ModTime) { + return true + } + if mod.Equal(best.ModTime) { + return relSlash < best.Path + } + return false +} + +func skipPreviewDir(name string) bool { + return strings.HasPrefix(name, ".") || name == "node_modules" +} + +// IsMarkdownPath reports whether p names a Markdown file the preview/files +// route should render to HTML rather than serve verbatim. +func IsMarkdownPath(p string) bool { + switch strings.ToLower(filepath.Ext(p)) { + case ".md", ".markdown": + return true + } + return false } // ConfinedPath maps an asset path into workspacePath and rejects paths that diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go new file mode 100644 index 000000000..705f6f169 --- /dev/null +++ b/backend/internal/preview/entry_test.go @@ -0,0 +1,122 @@ +package preview + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeEntryFile(t *testing.T, path, contents string, mod time.Time) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + if !mod.IsZero() { + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } +} + +func TestDiscoverEntryPrefersIndexOverNewerFile(t *testing.T) { + ws := t.TempDir() + base := time.Now() + writeEntryFile(t, filepath.Join(ws, "index.html"), "
app
", base) + // A newer report must not win against the conventional index.html anchor. + writeEntryFile(t, filepath.Join(ws, "report.html"), "
report
", base.Add(time.Hour)) + + entry, ok := DiscoverEntry(ws) + if !ok { + t.Fatal("DiscoverEntry: ok=false, want entry") + } + if entry.Path != "index.html" { + t.Fatalf("entry.Path = %q, want index.html", entry.Path) + } +} + +func TestDiscoverEntryFallsBackToMostRecentPreviewable(t *testing.T) { + ws := t.TempDir() + base := time.Now() + writeEntryFile(t, filepath.Join(ws, "old.html"), "
old
", base) + writeEntryFile(t, filepath.Join(ws, "docs", "notes.md"), "# notes", base.Add(30*time.Minute)) + writeEntryFile(t, filepath.Join(ws, "fresh.html"), "
fresh
", base.Add(time.Hour)) + + entry, ok := DiscoverEntry(ws) + if !ok { + t.Fatal("DiscoverEntry: ok=false, want fallback entry") + } + if entry.Path != "fresh.html" { + t.Fatalf("entry.Path = %q, want fresh.html", entry.Path) + } +} + +func TestDiscoverEntryFallsBackToMarkdown(t *testing.T) { + ws := t.TempDir() + writeEntryFile(t, filepath.Join(ws, "REPORT.md"), "# report", time.Now()) + + entry, ok := DiscoverEntry(ws) + if !ok { + t.Fatal("DiscoverEntry: ok=false, want markdown fallback") + } + if entry.Path != "REPORT.md" { + t.Fatalf("entry.Path = %q, want REPORT.md", entry.Path) + } +} + +func TestDiscoverEntrySkipsHiddenAndNodeModules(t *testing.T) { + ws := t.TempDir() + base := time.Now() + // Newest files live in skipped dirs; the visible one must win. + writeEntryFile(t, filepath.Join(ws, "node_modules", "pkg", "index.html"), "x", base.Add(time.Hour)) + writeEntryFile(t, filepath.Join(ws, ".cache", "cached.html"), "x", base.Add(2*time.Hour)) + writeEntryFile(t, filepath.Join(ws, "visible.html"), "ok", base) + + entry, ok := DiscoverEntry(ws) + if !ok { + t.Fatal("DiscoverEntry: ok=false, want visible entry") + } + if entry.Path != "visible.html" { + t.Fatalf("entry.Path = %q, want visible.html", entry.Path) + } +} + +func TestDiscoverEntryTieBreaksOnPath(t *testing.T) { + ws := t.TempDir() + mod := time.Now() + writeEntryFile(t, filepath.Join(ws, "b.html"), "b", mod) + writeEntryFile(t, filepath.Join(ws, "a.html"), "a", mod) + + entry, ok := DiscoverEntry(ws) + if !ok { + t.Fatal("DiscoverEntry: ok=false, want tie-break entry") + } + if entry.Path != "a.html" { + t.Fatalf("entry.Path = %q, want a.html (lexical tie-break)", entry.Path) + } +} + +func TestDiscoverEntryEmptyWorkspace(t *testing.T) { + if _, ok := DiscoverEntry(t.TempDir()); ok { + t.Fatal("DiscoverEntry: ok=true for empty workspace, want false") + } +} + +func TestIsMarkdownPath(t *testing.T) { + cases := map[string]bool{ + "a.md": true, + "A.MARKDOWN": true, + "dir/x.md": true, + "index.html": false, + "notes.txt": false, + "noext": false, + } + for in, want := range cases { + if got := IsMarkdownPath(in); got != want { + t.Errorf("IsMarkdownPath(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/backend/internal/preview/markdown.go b/backend/internal/preview/markdown.go new file mode 100644 index 000000000..496c9fc35 --- /dev/null +++ b/backend/internal/preview/markdown.go @@ -0,0 +1,103 @@ +package preview + +import ( + "bytes" + "html" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + ghtml "github.com/yuin/goldmark/renderer/html" +) + +// markdownRenderer converts workspace Markdown to HTML for the browser panel. +// GitHub-flavored extensions (tables, strikethrough, task lists, autolinks) are +// enabled, and raw HTML is passed through: preview content is workspace-local +// and agent-trusted, matching the preview target's existing trust model. +var markdownRenderer = goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(ghtml.WithUnsafe()), +) + +// RenderMarkdown converts Markdown source into a self-contained HTML document +// styled for the browser panel. title labels the document (typically the file +// name). +func RenderMarkdown(source []byte, title string) ([]byte, error) { + var body bytes.Buffer + if err := markdownRenderer.Convert(source, &body); err != nil { + return nil, err + } + var out bytes.Buffer + out.WriteString("\n\n\n") + out.WriteString("\n") + out.WriteString("\n") + out.WriteString("") + out.WriteString(html.EscapeString(title)) + out.WriteString("\n\n\n\n
\n") + out.Write(body.Bytes()) + out.WriteString("\n
\n\n\n") + return out.Bytes(), nil +} + +// markdownStyles is a compact GitHub-like stylesheet that honors the OS color +// scheme so the rendered Markdown fits the desktop browser panel in both light +// and dark modes. +const markdownStyles = ` +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { + margin: 0; + background: #ffffff; + color: #1f2328; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; +} +.markdown-body { + max-width: 860px; + margin: 0 auto; + padding: 32px 24px 64px; +} +.markdown-body h1, .markdown-body h2, .markdown-body h3, +.markdown-body h4, .markdown-body h5, .markdown-body h6 { + margin: 1.5em 0 0.6em; + font-weight: 600; + line-height: 1.25; +} +.markdown-body h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid rgba(128,128,128,0.25); } +.markdown-body h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid rgba(128,128,128,0.25); } +.markdown-body h3 { font-size: 1.25em; } +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table { + margin: 0 0 1em; +} +.markdown-body a { color: #3b82f6; text-decoration: none; } +.markdown-body a:hover { text-decoration: underline; } +.markdown-body code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; + background: rgba(128,128,128,0.15); + padding: 0.2em 0.4em; + border-radius: 6px; +} +.markdown-body pre { + background: rgba(128,128,128,0.12); + padding: 16px; + border-radius: 8px; + overflow: auto; +} +.markdown-body pre code { background: none; padding: 0; } +.markdown-body blockquote { + padding: 0 1em; + border-left: 4px solid rgba(128,128,128,0.35); + color: rgba(120,120,120,1); +} +.markdown-body table { border-collapse: collapse; width: auto; } +.markdown-body th, .markdown-body td { border: 1px solid rgba(128,128,128,0.3); padding: 6px 13px; } +.markdown-body img { max-width: 100%; } +.markdown-body hr { border: none; border-top: 1px solid rgba(128,128,128,0.25); margin: 2em 0; } +.markdown-body input[type="checkbox"] { margin-right: 0.5em; } +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #e6edf3; } +} +` diff --git a/backend/internal/preview/markdown_test.go b/backend/internal/preview/markdown_test.go new file mode 100644 index 000000000..52fce2469 --- /dev/null +++ b/backend/internal/preview/markdown_test.go @@ -0,0 +1,43 @@ +package preview + +import ( + "strings" + "testing" +) + +func TestRenderMarkdownProducesHTMLDocument(t *testing.T) { + out, err := RenderMarkdown([]byte("# Title\n\nHello **world**\n"), "notes.md") + if err != nil { + t.Fatalf("RenderMarkdown: %v", err) + } + got := string(out) + for _, want := range []string{"", "notes.md", "world"} { + if !strings.Contains(got, want) { + t.Errorf("rendered output missing %q\n%s", want, got) + } + } +} + +func TestRenderMarkdownGFMTable(t *testing.T) { + src := "| a | b |\n| - | - |\n| 1 | 2 |\n" + out, err := RenderMarkdown([]byte(src), "table.md") + if err != nil { + t.Fatalf("RenderMarkdown: %v", err) + } + if !strings.Contains(string(out), "") { + t.Errorf("GFM table not rendered:\n%s", out) + } +} + +func TestRenderMarkdownEscapesTitle(t *testing.T) { + out, err := RenderMarkdown([]byte("hi"), ".md") + if err != nil { + t.Fatalf("RenderMarkdown: %v", err) + } + if strings.Contains(string(out), "<x>.md") { + t.Errorf("title not escaped:\n%s", out) + } + if !strings.Contains(string(out), "<x>.md") { + t.Errorf("expected escaped title, got:\n%s", out) + } +}