mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-05 17:12:33 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76b7b0415 | ||
|
|
3aa5b88a95 |
No files matched your search
+3
-1
@@ -10,7 +10,9 @@ backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/vendor/**
|
||||
backend/internal/web/dist/*
|
||||
!backend/internal/web/dist/.gitkeep
|
||||
|
||||
|
||||
frontend/dist/**
|
||||
frontend/node_modules/**
|
||||
frontend/pkg/**
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: backend/internal/web/dist
|
||||
path: frontend/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: backend/internal/web/dist
|
||||
path: frontend/dist
|
||||
- name: Download backend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Office previews can use embedded thumbnail if possible.
|
||||
- Text file previews are more efficient direct creation.
|
||||
- New text preview API (`/api/resources/preview/text`) returns a 16KB text preview for text, Markdown, HTML, CSS, code and RTF files. Markdown and HTML render in the listing as sanitized previews. Previews are not cached on disk. SVG files are served directly as images.
|
||||
- Text previews only render inline on tiles at least 60px wide, so narrower tiles (list, compact, normal) show the file-type icon with a `saved_search` badge instead when popup previews are enabled, and hovering still opens the larger popup preview. Text preview reads are bounded by the preview service's shared image-processor semaphore (`server.numImageProcessors`), and rendered preview HTML is cached in the browser so scrolling and hovering do not re-parse it.
|
||||
- Embedded previews for Krita (`.kra`, [#2431](https://github.com/gtsteffaniak/filebrowser/issues/2431)), OpenRaster (`.ora`), Sketch (`.sketch`), MuseScore (`.mscz`), CBZ (first page), XPS (embedded thumbnail, when present), ODF (`.odt`, `.ods`, `.odp`, `.odg`), and Office Open XML (`.docx`, `.xlsx`, `.pptx`) thumbnails.
|
||||
- Virtual scrolling for large lists: like the main listing, advanced search, file tree, etc. Only render the rows on screen, so very large folders stay responsive, resolving some performance issues (#2879) (#1773) (#3320) (#3326).
|
||||
- Search and navigation improvements:
|
||||
@@ -45,6 +46,10 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Media probes are cached by file identity and concurrent reads are coalesced, so opening a file no longer triggers repeated ffprobe runs.
|
||||
- Client-initiated stream cancels (seek/navigation) no longer log spurious write-error responses.
|
||||
- Gzipped `.woff` font files are no longer generated without being served; only `.js` and `.woff2` are precompressed.
|
||||
- Text preview tiles no longer show the bottom truncation fade; truncated content is clipped at the tile edge instead.
|
||||
- Text preview tiles lay out at a minimum width and are clipped, instead of wrapping aggressively on narrow tiles.
|
||||
- Queued text preview requests are canceled when their tiles unmount, so scrolling through a folder of text files and then opening another folder no longer delays the new folder's previews.
|
||||
- Removed the stale, unreferenced `backend/internal/web/embed/` build output that was accidentally re-added; the frontend is embedded from `backend/internal/web/dist/` as before.
|
||||
- Advanced search no longer loses the previously selected sorting (#3275)
|
||||
- Context menu in iOS don't opening in Advanced Search (#2776) (#3299)
|
||||
- Improved mobile layout of inputs in search filters.
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
RUN [ "ffmpeg", "-version" ]
|
||||
|
||||
@@ -34,7 +34,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
|
||||
@@ -68,7 +68,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
|
||||
|
||||
RUN filebrowser version && ffmpeg -version && ffprobe -version
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
|
||||
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser user set testuser1 --password testuser1
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app/backend/
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./_docker/src/oidc/backend/", "./"]
|
||||
COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -23,7 +23,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
ENV CI=true
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -13,7 +13,7 @@ WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
|
||||
COPY [ "./frontend/dist/", "./internal/web/dist/"]
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -33,7 +33,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
|
||||
@@ -32,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.6.2
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/gtsteffaniak/go-media v1.2.0
|
||||
github.com/gtsteffaniak/go-media v1.2.1
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
|
||||
@@ -167,6 +167,8 @@ github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcd
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
|
||||
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
|
||||
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
|
||||
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Package folio is the pure-Go document preview fast path using gen2brain/folio.
|
||||
// When folio cannot handle a file, callers fall back to the office converter (when configured).
|
||||
package folio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gen2brain/folio/doc"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// RenderOptions controls thumbnail sizing for one rendered page.
|
||||
type RenderOptions struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Fill bool
|
||||
JpegQuality int
|
||||
Original bool
|
||||
}
|
||||
|
||||
// Enabled reports whether the folio fast path may run.
|
||||
func Enabled() bool {
|
||||
return !settings.AlwaysUseOnlyOfficeEnabled()
|
||||
}
|
||||
|
||||
// SupportsExtension reports whether folio targets the file extension (including leading dot).
|
||||
func SupportsExtension(ext string) bool {
|
||||
ext = strings.ToLower(ext)
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
return iteminfo.FolioConvertable[ext]
|
||||
}
|
||||
|
||||
func logFallback(op, path string, err error) {
|
||||
if errors.Is(err, doc.ErrUnsupported) {
|
||||
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
|
||||
return
|
||||
}
|
||||
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
|
||||
}
|
||||
|
||||
// RenderPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
|
||||
// name and mimetype should match the file being previewed; they control eligibility for
|
||||
// folio (content sniffing does not depend on the path extension).
|
||||
func RenderPageJPEG(ctx context.Context, path string, pageNumber int, opts RenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
|
||||
if !Enabled() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if name == "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
if !SupportsPreview(name, mimetype) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, false, ctx.Err()
|
||||
}
|
||||
|
||||
type result struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
|
||||
}
|
||||
}()
|
||||
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
|
||||
done <- result{data: data, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, false, ctx.Err()
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
|
||||
logFallback("render", path, res.err)
|
||||
}
|
||||
return nil, false, res.err
|
||||
}
|
||||
if len(res.data) == 0 {
|
||||
return nil, false, fmt.Errorf("folio render returned empty jpeg")
|
||||
}
|
||||
return res.data, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts RenderOptions) ([]byte, error) {
|
||||
renderMu.Lock()
|
||||
defer renderMu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d, err := openDocument(path, name, mimetype)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if pageNumber < 0 || pageNumber >= d.NumPages() {
|
||||
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
|
||||
}
|
||||
|
||||
p, err := d.Page(pageNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var img image.Image
|
||||
if opts.Original || opts.MaxWidth <= 0 || opts.MaxHeight <= 0 {
|
||||
const maxEdge = 4096
|
||||
rgba, err := p.ImageDPI(150)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img = rgba
|
||||
w, h := rgba.Bounds().Dx(), rgba.Bounds().Dy()
|
||||
if w > maxEdge || h > maxEdge {
|
||||
scale := min(float64(maxEdge)/float64(w), float64(maxEdge)/float64(h))
|
||||
newW := max(1, int(float64(w)*scale))
|
||||
newH := max(1, int(float64(h)*scale))
|
||||
img = imaging.Resize(img, newW, newH, imaging.Lanczos)
|
||||
}
|
||||
} else {
|
||||
rgba, err := p.ImageDPI(150)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img = rgba
|
||||
if opts.Fill {
|
||||
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
|
||||
} else {
|
||||
b := img.Bounds()
|
||||
if b.Dx() > opts.MaxWidth || b.Dy() > opts.MaxHeight {
|
||||
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q := opts.JpegQuality
|
||||
if q <= 0 {
|
||||
q = 85
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -5,16 +5,26 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
const (
|
||||
// docPreviewTimeout bounds small, large and xlarge page renders.
|
||||
docPreviewTimeout = 2 * time.Second
|
||||
// docOriginalTimeout bounds "original" renders, which are higher resolution.
|
||||
docOriginalTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, pageNumber int, previewSize string) ([]byte, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
timeout := docPreviewTimeout
|
||||
if previewSize == "original" {
|
||||
timeout = docOriginalTimeout
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
opts, err := folioRenderOptions(previewSize)
|
||||
@@ -22,7 +32,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageBytes, ok, err := folio.RenderPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
|
||||
imageBytes, ok, err := renderDocPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
|
||||
if !ok {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -30,20 +40,22 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, nil
|
||||
}
|
||||
if timeoutCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
|
||||
return nil, fmt.Errorf("document preview generation timed out after %s for '%s'", timeout, file.Name)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
func folioRenderOptions(previewSize string) (folio.RenderOptions, error) {
|
||||
// folioRenderOptions maps a preview size onto the same dimensions, fill mode and JPEG quality
|
||||
// the image pipeline uses (see getPreviewOptions); "original" renders at full resolution.
|
||||
func folioRenderOptions(previewSize string) (docRenderOptions, error) {
|
||||
if previewSize == "original" {
|
||||
return folio.RenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
|
||||
return docRenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
|
||||
}
|
||||
opts, err := getPreviewOptions(previewSize)
|
||||
if err != nil {
|
||||
return folio.RenderOptions{}, err
|
||||
return docRenderOptions{}, err
|
||||
}
|
||||
return folio.RenderOptions{
|
||||
return docRenderOptions{
|
||||
MaxWidth: opts.Width,
|
||||
MaxHeight: opts.Height,
|
||||
Fill: opts.ResizeMode == ResizeModeFill,
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -17,7 +16,7 @@ func TestFolioRender_utf8TruncatedPlaywrightFile(t *testing.T) {
|
||||
}
|
||||
opts, err := folioRenderOptions("small")
|
||||
require.NoError(t, err)
|
||||
data, ok, err := folio.RenderPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
|
||||
data, ok, err := renderDocPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "err=%v", err)
|
||||
require.NotEmpty(t, data)
|
||||
@@ -29,7 +28,7 @@ func TestFolioRender_cssAsTxt(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(cssPath, []byte("body { color: red; }\n"), 0o644))
|
||||
opts, err := folioRenderOptions("small")
|
||||
require.NoError(t, err)
|
||||
data, ok, err := folio.RenderPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
|
||||
data, ok, err := renderDocPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "err=%v", err)
|
||||
require.NotEmpty(t, data)
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// folio.go is the pure-Go document preview fast path using gen2brain/folio.
|
||||
// When folio cannot handle a file, callers fall back to the office converter (when configured).
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gen2brain/folio/doc"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
const (
|
||||
// docSupersample renders a page at this multiple of the target box before the
|
||||
// final downscale. Rasterized text needs the extra pixels to stay crisp.
|
||||
docSupersample = 2
|
||||
// docOriginalDPI is the resolution used for "original" page previews.
|
||||
docOriginalDPI = 200
|
||||
// docMaxRenderEdge caps the longest edge of a rendered page, in pixels.
|
||||
docMaxRenderEdge = 4096
|
||||
// docMinDPI and docMaxDPI bound the computed render resolution.
|
||||
docMinDPI = 36.0
|
||||
docMaxDPI = 600.0
|
||||
)
|
||||
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// docRenderOptions controls the size and quality of one rendered document page.
|
||||
type docRenderOptions struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
Fill bool
|
||||
JpegQuality int
|
||||
Original bool
|
||||
}
|
||||
|
||||
// folioEnabled reports whether the folio fast path may run.
|
||||
func folioEnabled() bool {
|
||||
return !settings.AlwaysUseOnlyOfficeEnabled()
|
||||
}
|
||||
|
||||
func logFolioFallback(op, path string, err error) {
|
||||
if errors.Is(err, doc.ErrUnsupported) {
|
||||
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
|
||||
return
|
||||
}
|
||||
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
|
||||
}
|
||||
|
||||
// renderDocPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
|
||||
// name and mimetype should match the file being previewed; they control eligibility for
|
||||
// folio (content sniffing does not depend on the path extension).
|
||||
func renderDocPageJPEG(ctx context.Context, path string, pageNumber int, opts docRenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
|
||||
if !folioEnabled() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if name == "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
if !folioSupportsPreview(name, mimetype) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, false, ctx.Err()
|
||||
}
|
||||
|
||||
type result struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
|
||||
}
|
||||
}()
|
||||
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
|
||||
done <- result{data: data, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, false, ctx.Err()
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
|
||||
logFolioFallback("render", path, res.err)
|
||||
}
|
||||
return nil, false, res.err
|
||||
}
|
||||
if len(res.data) == 0 {
|
||||
return nil, false, fmt.Errorf("folio render returned empty jpeg")
|
||||
}
|
||||
return res.data, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts docRenderOptions) ([]byte, error) {
|
||||
renderMu.Lock()
|
||||
defer renderMu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d, err := openDocument(path, name, mimetype)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if pageNumber < 0 || pageNumber >= d.NumPages() {
|
||||
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
|
||||
}
|
||||
|
||||
p, err := d.Page(pageNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boxed := !opts.Original && opts.MaxWidth > 0 && opts.MaxHeight > 0
|
||||
var dpi float64
|
||||
if boxed {
|
||||
dpi = docBoxRenderDPI(p, d.Kind(), opts.MaxWidth, opts.MaxHeight, opts.Fill)
|
||||
} else {
|
||||
dpi = docOriginalRenderDPI(p, d.Kind())
|
||||
}
|
||||
|
||||
rendered, err := p.ImageDPI(dpi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rendered == nil {
|
||||
return nil, fmt.Errorf("folio rendered no image for page %d", pageNumber)
|
||||
}
|
||||
|
||||
var img image.Image = rendered
|
||||
if boxed {
|
||||
// The page was rendered at docSupersample times the target, so this
|
||||
// single high-quality downscale produces the anti-aliased final image.
|
||||
if opts.Fill {
|
||||
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
|
||||
} else {
|
||||
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
|
||||
q := opts.JpegQuality
|
||||
if q <= 0 {
|
||||
q = 85
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// docUnitDPI is the resolution at which folio's ImageDPI renders page units one to one:
|
||||
// 72 for PDF and book pages, 96 for drawings (SVG).
|
||||
func docUnitDPI(kind doc.Kind) float64 {
|
||||
if kind == doc.KindSVG {
|
||||
return 96
|
||||
}
|
||||
return 72
|
||||
}
|
||||
|
||||
// docMaxEdgeDPI is the highest resolution at which the longest page edge stays within docMaxRenderEdge.
|
||||
func docMaxEdgeDPI(p doc.Page, kind doc.Kind) float64 {
|
||||
b := p.Bounds()
|
||||
longest := float64(max(b.X1-b.X0, b.Y1-b.Y0))
|
||||
if longest <= 0 {
|
||||
return docMinDPI
|
||||
}
|
||||
return docUnitDPI(kind) * docMaxRenderEdge / longest
|
||||
}
|
||||
|
||||
// docBoxRenderDPI picks the resolution for a page that will be scaled into a boxW x boxH target.
|
||||
// Fill covers the box; fit stays inside it. The page is rendered at docSupersample times the target.
|
||||
func docBoxRenderDPI(p doc.Page, kind doc.Kind, boxW, boxH int, fill bool) float64 {
|
||||
b := p.Bounds()
|
||||
pw, ph := float64(b.X1-b.X0), float64(b.Y1-b.Y0)
|
||||
if pw <= 0 || ph <= 0 {
|
||||
return docMinDPI
|
||||
}
|
||||
// Scale (pixels per page unit) needed for the supersampled render to reach the box on each axis.
|
||||
sx := float64(boxW*docSupersample) / pw
|
||||
sy := float64(boxH*docSupersample) / ph
|
||||
s := min(sx, sy)
|
||||
if fill {
|
||||
s = max(sx, sy)
|
||||
}
|
||||
dpi := min(s*docUnitDPI(kind), docMaxEdgeDPI(p, kind))
|
||||
return min(max(dpi, docMinDPI), docMaxDPI)
|
||||
}
|
||||
|
||||
// docOriginalRenderDPI picks the resolution for "original" previews: docOriginalDPI, capped by docMaxRenderEdge.
|
||||
func docOriginalRenderDPI(p doc.Page, kind doc.Kind) float64 {
|
||||
return min(docOriginalDPI, docMaxEdgeDPI(p, kind))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package folio
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -17,9 +17,9 @@ const folioTextCheckWindow = 4096 // html.readsAsText validation window
|
||||
// maxTextPreviewLoad is the most plain-text we read for a first-page thumbnail.
|
||||
const maxTextPreviewLoad = 32 << 10 // 32 KiB
|
||||
|
||||
// SupportsPreview reports whether folio should attempt this file (name + MIME).
|
||||
// folioSupportsPreview reports whether folio should attempt this file (name + MIME).
|
||||
// doc.Open sniffs content; extensions like .css are not in FolioConvertable but still render.
|
||||
func SupportsPreview(name, mimetype string) bool {
|
||||
func folioSupportsPreview(name, mimetype string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if iteminfo.FolioConvertable[ext] {
|
||||
return true
|
||||
@@ -1,4 +1,4 @@
|
||||
package folio
|
||||
package preview
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderPageJPEG_txt(t *testing.T) {
|
||||
func TestRenderDocPageJPEG_txt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hello.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("Hello folio preview"), 0o644))
|
||||
|
||||
data, ok, err := RenderPageJPEG(t.Context(), path, 0, RenderOptions{
|
||||
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, docRenderOptions{
|
||||
MaxWidth: 256,
|
||||
MaxHeight: 256,
|
||||
Fill: true,
|
||||
@@ -26,8 +26,8 @@ func TestRenderPageJPEG_txt(t *testing.T) {
|
||||
require.Equal(t, byte(0xd8), data[1])
|
||||
}
|
||||
|
||||
func TestRenderPageJPEG_unsupportedExtension(t *testing.T) {
|
||||
data, ok, err := RenderPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, RenderOptions{}, "file.xyz", "application/octet-stream")
|
||||
func TestRenderDocPageJPEG_unsupportedExtension(t *testing.T) {
|
||||
data, ok, err := renderDocPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, docRenderOptions{}, "file.xyz", "application/octet-stream")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, data)
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
package folio
|
||||
package preview
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -37,22 +37,17 @@ func TestSanitizeFolioText_stripsNUL(t *testing.T) {
|
||||
raw := []byte("hello\x00world")
|
||||
got := sanitizeFolioText(raw)
|
||||
require.Equal(t, []byte("hello world"), got)
|
||||
opts, err := folioRenderOptionsForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := docRenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nul.txt")
|
||||
require.NoError(t, os.WriteFile(path, raw, 0o600))
|
||||
data, ok, err := RenderPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
|
||||
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "err=%v", err)
|
||||
require.NotEmpty(t, data)
|
||||
}
|
||||
|
||||
func folioRenderOptionsForTest() (RenderOptions, error) {
|
||||
return RenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}, nil
|
||||
}
|
||||
|
||||
func TestReadTextPreviewPrefix_cap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "big.log")
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/embeddedpreview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -187,6 +186,16 @@ func GetService() *Service {
|
||||
return service
|
||||
}
|
||||
|
||||
// GetTextPreview returns a text preview for realPath, bounded by the preview service's
|
||||
// shared image-processor semaphore. It returns ErrPreviewServiceUnavailable when the
|
||||
// service has not been started.
|
||||
func GetTextPreview(ctx context.Context, realPath, kind string) (TextPreviewResult, error) {
|
||||
if service == nil {
|
||||
return TextPreviewResult{}, ErrPreviewServiceUnavailable
|
||||
}
|
||||
return service.ReadTextPreview(ctx, realPath, kind)
|
||||
}
|
||||
|
||||
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
|
||||
if !file.HasPreview {
|
||||
return nil, ErrUnsupportedMedia
|
||||
@@ -471,7 +480,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
|
||||
// Folio document previews render at target size in-process; they skip imageSem.
|
||||
previewTypeEarly := determinePreviewType(file)
|
||||
skipImageSem := previewTypeEarly == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
|
||||
skipImageSem := previewTypeEarly == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
|
||||
// True when the semaphore block below holds a permit for this whole function.
|
||||
imagePermitHeld := !skipImageSem
|
||||
if !skipImageSem {
|
||||
@@ -566,7 +575,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
// Video thumbnails come from go-media or ffmpeg already sized to the preview
|
||||
// box, so they are final and need no ffmpeg gate here.
|
||||
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
|
||||
(previewType == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
|
||||
(previewType == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
|
||||
previewType == previewTypeVideo) {
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache image: %v", err)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Text preview: a size-capped prefix of a text-like file for the text preview API.
|
||||
//
|
||||
// Output is not sanitized; the frontend is responsible for sanitizing everything it
|
||||
// renders from these results. ReadTextPreview does not generate an image and is not
|
||||
// written to the disk cache, so it is separate from the image preview pipeline.
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MaxTextPreviewBytes is the largest amount of a file that is read for a preview.
|
||||
const MaxTextPreviewBytes = 16 * 1024
|
||||
|
||||
// ErrNotText is returned for files that look binary (contain NUL bytes).
|
||||
var ErrNotText = errors.New("file does not look like text")
|
||||
|
||||
// ErrPreviewServiceUnavailable is returned by text preview entry points when the
|
||||
// preview service has not been started.
|
||||
var ErrPreviewServiceUnavailable = errors.New("preview service not initialized")
|
||||
|
||||
// TextPreviewResult is the JSON payload returned by the text preview endpoint.
|
||||
type TextPreviewResult struct {
|
||||
// Content is plain text, or an HTML fragment when Format is "html".
|
||||
Content string `json:"content"`
|
||||
// Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.
|
||||
Kind string `json:"kind"`
|
||||
// Format is "text" or "html". RTF is converted to HTML; everything else is text.
|
||||
Format string `json:"format"`
|
||||
// Truncated is true when the file is larger than the returned content.
|
||||
Truncated bool `json:"truncated"`
|
||||
// Size is the full size of the file in bytes.
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ReadTextPreview returns up to MaxTextPreviewBytes of the file at realPath, prepared
|
||||
// for the given renderer kind. It reads directly from disk and does not take an image
|
||||
// processor permit; callers that need to bound concurrent reads use the service's
|
||||
// shared semaphore (see Service.ReadTextPreview).
|
||||
func ReadTextPreview(realPath, kind string) (TextPreviewResult, error) {
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return TextPreviewResult{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return TextPreviewResult{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, MaxTextPreviewBytes)
|
||||
n, err := io.ReadFull(f, buf)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return TextPreviewResult{}, err
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
if bytes.IndexByte(buf, 0) >= 0 {
|
||||
return TextPreviewResult{}, ErrNotText
|
||||
}
|
||||
|
||||
truncated := info.Size() > int64(n)
|
||||
if truncated {
|
||||
buf = trimIncompleteRune(buf)
|
||||
buf = trimToLastLine(buf)
|
||||
}
|
||||
|
||||
// Legacy encodings are not valid UTF-8. Replace the bad bytes so the preview still renders.
|
||||
text := strings.ToValidUTF8(string(buf), "\uFFFD")
|
||||
|
||||
result := TextPreviewResult{Kind: kind, Format: "text", Truncated: truncated, Size: info.Size()}
|
||||
if kind == "rtf" {
|
||||
result.Content = RTFToHTML(text)
|
||||
result.Format = "html"
|
||||
} else {
|
||||
result.Content = text
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadTextPreview reads a text preview while holding a shared image-processor permit,
|
||||
// so text reads and conversions count against the same budget as image work instead of
|
||||
// opening an unbounded number of file handles when a listing is full of text files.
|
||||
// It returns ErrPreviewServiceUnavailable when the service has not been started.
|
||||
func (s *Service) ReadTextPreview(ctx context.Context, realPath, kind string) (TextPreviewResult, error) {
|
||||
if err := s.acquireImageSem(ctx); err != nil {
|
||||
return TextPreviewResult{}, err
|
||||
}
|
||||
defer s.releaseImageSem()
|
||||
// The acquire select can win the slot at the same moment the request is canceled
|
||||
// (both cases ready), so re-check before doing any file I/O for a dead request.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TextPreviewResult{}, err
|
||||
}
|
||||
return ReadTextPreview(realPath, kind)
|
||||
}
|
||||
|
||||
// trimIncompleteRune removes a trailing partial UTF-8 sequence left by the read limit.
|
||||
func trimIncompleteRune(b []byte) []byte {
|
||||
for i := 1; i <= utf8.UTFMax && i <= len(b); i++ {
|
||||
c := b[len(b)-i]
|
||||
if c&0xC0 == 0x80 {
|
||||
// Continuation byte: keep looking for the lead byte.
|
||||
continue
|
||||
}
|
||||
need := 1
|
||||
switch {
|
||||
case c&0xE0 == 0xC0:
|
||||
need = 2
|
||||
case c&0xF0 == 0xE0:
|
||||
need = 3
|
||||
case c&0xF8 == 0xF0:
|
||||
need = 4
|
||||
}
|
||||
if need > i {
|
||||
return b[:len(b)-i]
|
||||
}
|
||||
return b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// trimToLastLine cuts at the last newline when that keeps at least half of the buffer.
|
||||
// Files with very long lines keep their full prefix so the preview is not empty.
|
||||
func trimToLastLine(b []byte) []byte {
|
||||
idx := bytes.LastIndexByte(b, '\n')
|
||||
if idx > len(b)/2 {
|
||||
return b[:idx]
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package textpreview
|
||||
package preview
|
||||
|
||||
import (
|
||||
"html"
|
||||
+46
-8
@@ -1,6 +1,7 @@
|
||||
package textpreview
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -43,15 +44,15 @@ func TestReadTruncatesAndKeepsUTF8Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "big.txt")
|
||||
// Multibyte characters so the 16KB cut is likely to land inside one.
|
||||
content := strings.Repeat("\u00e9\n", MaxBytes)
|
||||
content := strings.Repeat("\u00e9\n", MaxTextPreviewBytes)
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
|
||||
res, err := Read(path, "plain")
|
||||
res, err := ReadTextPreview(path, "plain")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Truncated)
|
||||
require.Equal(t, "plain", res.Kind)
|
||||
require.Equal(t, "text", res.Format)
|
||||
require.LessOrEqual(t, len(res.Content), MaxBytes)
|
||||
require.LessOrEqual(t, len(res.Content), MaxTextPreviewBytes)
|
||||
require.True(t, strings.HasSuffix(res.Content, "\u00e9"), "cut must land on a whole line")
|
||||
require.Equal(t, int64(len(content)), res.Size)
|
||||
}
|
||||
@@ -60,7 +61,7 @@ func TestReadSmallFileNotTruncated(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "small.md")
|
||||
require.NoError(t, os.WriteFile(path, []byte("# Title\n"), 0o600))
|
||||
|
||||
res, err := Read(path, "markdown")
|
||||
res, err := ReadTextPreview(path, "markdown")
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Truncated)
|
||||
require.Equal(t, "# Title\n", res.Content)
|
||||
@@ -70,7 +71,7 @@ func TestReadRTFReturnsHTML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "doc.rtf")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{\rtf1 Hi\par There}`), 0o600))
|
||||
|
||||
res, err := Read(path, "rtf")
|
||||
res, err := ReadTextPreview(path, "rtf")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "html", res.Format)
|
||||
require.Equal(t, "<p>Hi</p><p>There</p>", res.Content)
|
||||
@@ -80,7 +81,7 @@ func TestReadRejectsBinary(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "data.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("text\x00binary"), 0o600))
|
||||
|
||||
_, err := Read(path, "plain")
|
||||
_, err := ReadTextPreview(path, "plain")
|
||||
require.ErrorIs(t, err, ErrNotText)
|
||||
}
|
||||
|
||||
@@ -88,7 +89,44 @@ func TestReadReplacesInvalidUTF8(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "latin1.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("caf\xe9"), 0o600))
|
||||
|
||||
res, err := Read(path, "plain")
|
||||
res, err := ReadTextPreview(path, "plain")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "caf\uFFFD", res.Content)
|
||||
}
|
||||
|
||||
// A single-processor service has a one-slot imageSem, so text previews must serialize
|
||||
// behind it rather than racing ahead of image work.
|
||||
func TestServiceReadTextPreviewSharesImageSemaphore(t *testing.T) {
|
||||
svc := NewPreviewGenerator(1, t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "notes.md")
|
||||
require.NoError(t, os.WriteFile(path, []byte("# Title\n"), 0o600))
|
||||
|
||||
res, err := svc.ReadTextPreview(t.Context(), path, "markdown")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "# Title\n", res.Content)
|
||||
require.Equal(t, 0, len(svc.imageSem), "permit must be released after the read")
|
||||
}
|
||||
|
||||
func TestServiceReadTextPreviewHonoursCancellation(t *testing.T) {
|
||||
svc := NewPreviewGenerator(1, t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "notes.md")
|
||||
require.NoError(t, os.WriteFile(path, []byte("# Title\n"), 0o600))
|
||||
|
||||
// Hold the only permit, then confirm a cancelled caller does not block forever.
|
||||
require.NoError(t, svc.acquireImageSem(t.Context()))
|
||||
defer svc.releaseImageSem()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
_, err := svc.ReadTextPreview(ctx, path, "markdown")
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestGetTextPreviewWithoutService(t *testing.T) {
|
||||
original := service
|
||||
service = nil
|
||||
t.Cleanup(func() { service = original })
|
||||
|
||||
_, err := GetTextPreview(t.Context(), "ignored.md", "markdown")
|
||||
require.ErrorIs(t, err, ErrPreviewServiceUnavailable)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Package textpreview reads a size-capped prefix of a text-like file for the text preview API.
|
||||
//
|
||||
// It does not use the image preview pipeline and does not sanitize output. The frontend
|
||||
// is responsible for sanitizing everything it renders from this package.
|
||||
package textpreview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MaxBytes is the largest amount of a file that is read for a preview.
|
||||
const MaxBytes = 16 * 1024
|
||||
|
||||
// ErrNotText is returned for files that look binary (contain NUL bytes).
|
||||
var ErrNotText = errors.New("file does not look like text")
|
||||
|
||||
// Result is the JSON payload returned by the text preview endpoint.
|
||||
type Result struct {
|
||||
// Content is plain text, or an HTML fragment when Format is "html".
|
||||
Content string `json:"content"`
|
||||
// Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.
|
||||
Kind string `json:"kind"`
|
||||
// Format is "text" or "html". RTF is converted to HTML; everything else is text.
|
||||
Format string `json:"format"`
|
||||
// Truncated is true when the file is larger than the returned content.
|
||||
Truncated bool `json:"truncated"`
|
||||
// Size is the full size of the file in bytes.
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Read returns up to MaxBytes of the file at realPath, prepared for the given renderer kind.
|
||||
func Read(realPath, kind string) (Result, error) {
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, MaxBytes)
|
||||
n, err := io.ReadFull(f, buf)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return Result{}, err
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
if bytes.IndexByte(buf, 0) >= 0 {
|
||||
return Result{}, ErrNotText
|
||||
}
|
||||
|
||||
truncated := info.Size() > int64(n)
|
||||
if truncated {
|
||||
buf = trimIncompleteRune(buf)
|
||||
buf = trimToLastLine(buf)
|
||||
}
|
||||
|
||||
// Legacy encodings are not valid UTF-8. Replace the bad bytes so the preview still renders.
|
||||
text := strings.ToValidUTF8(string(buf), "\uFFFD")
|
||||
|
||||
result := Result{Kind: kind, Format: "text", Truncated: truncated, Size: info.Size()}
|
||||
if kind == "rtf" {
|
||||
result.Content = RTFToHTML(text)
|
||||
result.Format = "html"
|
||||
} else {
|
||||
result.Content = text
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// trimIncompleteRune removes a trailing partial UTF-8 sequence left by the read limit.
|
||||
func trimIncompleteRune(b []byte) []byte {
|
||||
for i := 1; i <= utf8.UTFMax && i <= len(b); i++ {
|
||||
c := b[len(b)-i]
|
||||
if c&0xC0 == 0x80 {
|
||||
// Continuation byte: keep looking for the lead byte.
|
||||
continue
|
||||
}
|
||||
need := 1
|
||||
switch {
|
||||
case c&0xE0 == 0xC0:
|
||||
need = 2
|
||||
case c&0xF0 == 0xE0:
|
||||
need = 3
|
||||
case c&0xF8 == 0xF0:
|
||||
need = 4
|
||||
}
|
||||
if need > i {
|
||||
return b[:len(b)-i]
|
||||
}
|
||||
return b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// trimToLastLine cuts at the last newline when that keeps at least half of the buffer.
|
||||
// Files with very long lines keep their full prefix so the preview is not empty.
|
||||
func trimToLastLine(b []byte) []byte {
|
||||
idx := bytes.LastIndexByte(b, '\n')
|
||||
if idx > len(b)/2 {
|
||||
return b[:idx]
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func initRuntime(deps Deps, assets fs.FS) {
|
||||
initSettingsSecretMarkers()
|
||||
}
|
||||
|
||||
// Embed the frontend build output under internal/web/dist (see frontend `npm run build`).
|
||||
// Embed the frontend build output under internal/web/dist (see `make embed-frontend` after `npm run build` in frontend/).
|
||||
//
|
||||
//go:embed dist/*
|
||||
var assets embed.FS
|
||||
|
||||
@@ -245,6 +245,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
manifestURL = staticURL + "/site.webmanifest?" + query.Encode()
|
||||
}
|
||||
|
||||
// only include constant values that don't change during a users session
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
|
||||
@@ -272,6 +273,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
// only include constant values that don't change during a users session
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
@@ -302,7 +304,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"transcodeProfiles": settings.EnabledTranscodeProfiles(),
|
||||
"transcodeMaxPerUser": settings.TranscodeMaxConcurrentPerUser(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"textPreviewExtensions": iteminfo.TextPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/textpreview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -24,7 +22,7 @@ import (
|
||||
// @Produce json
|
||||
// @Param path query string true "File path of the text file to preview"
|
||||
// @Param source query string true "Source name"
|
||||
// @Success 200 {object} textpreview.Result "Text preview content"
|
||||
// @Success 200 {object} preview.TextPreviewResult "Text preview content"
|
||||
// @Failure 400 {object} map[string]string "Invalid request path"
|
||||
// @Failure 403 {object} map[string]string "User is not allowed to view files in this source"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
@@ -71,7 +69,7 @@ func textPreviewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param path query string true "File path within the share to preview"
|
||||
// @Success 200 {object} textpreview.Result "Text preview content"
|
||||
// @Success 200 {object} preview.TextPreviewResult "Text preview content"
|
||||
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Failure 415 {object} map[string]string "File type is not supported for text preview, or the file is binary"
|
||||
@@ -101,14 +99,17 @@ func serveTextPreview(w http.ResponseWriter, r *http.Request, info *iteminfo.Ext
|
||||
if info.Type == "directory" {
|
||||
return http.StatusUnsupportedMediaType, fmt.Errorf("directories have no text preview")
|
||||
}
|
||||
kind := iteminfo.TextPreviewKind(strings.ToLower(filepath.Ext(info.Name)))
|
||||
if kind == "" {
|
||||
if !iteminfo.IsTextPreviewable(info.Type) {
|
||||
return http.StatusUnsupportedMediaType, fmt.Errorf("unsupported file type for text preview")
|
||||
}
|
||||
result, err := textpreview.Read(info.RealPath, kind)
|
||||
kind := iteminfo.TextPreviewRenderKind(info.Name)
|
||||
// Bound concurrent reads and RTF conversions with the preview service's shared
|
||||
// image-processor semaphore, so a listing of many text files cannot open an
|
||||
// unbounded number of file handles at once.
|
||||
result, err := preview.GetTextPreview(r.Context(), info.RealPath, kind)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, textpreview.ErrNotText):
|
||||
case errors.Is(err, preview.ErrNotText):
|
||||
return http.StatusUnsupportedMediaType, err
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return http.StatusNotFound, fmt.Errorf("file not found")
|
||||
|
||||
@@ -1597,7 +1597,7 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
// Text-like files are rendered by the text preview endpoint (not the image pipeline),
|
||||
// so this runs before Folio/OnlyOffice. SVG is not a text preview type and stays an image.
|
||||
if iteminfo.IsTextPreviewable(fileInfo.Name) {
|
||||
if iteminfo.IsTextPreviewable(fileInfo.Type) {
|
||||
fileInfo.HasPreview = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -560,20 +560,17 @@ func TextPreviewKind(ext string) string {
|
||||
return textPreviewKinds[ext]
|
||||
}
|
||||
|
||||
// IsTextPreviewable reports whether a file name uses the text preview endpoint.
|
||||
func IsTextPreviewable(name string) bool {
|
||||
return textPreviewKinds[strings.ToLower(filepath.Ext(name))] != ""
|
||||
// IsTextPreviewable reports whether an item uses the text preview endpoint. The decision
|
||||
// is the item's mimetype: every text/* type is previewable, and everything else is not.
|
||||
func IsTextPreviewable(mimetype string) bool {
|
||||
return strings.HasPrefix(mimetype, "text/")
|
||||
}
|
||||
|
||||
// TextPreviewExtensionsForSPA groups text preview extensions (without dots) by renderer kind.
|
||||
// The frontend uses this to decide which items get a text preview container.
|
||||
func TextPreviewExtensionsForSPA() map[string][]string {
|
||||
out := make(map[string][]string)
|
||||
for ext, kind := range textPreviewKinds {
|
||||
out[kind] = append(out[kind], strings.TrimPrefix(ext, "."))
|
||||
// TextPreviewRenderKind returns the renderer kind for a text-previewable file: the
|
||||
// extension-specific kind when one exists, otherwise "plain".
|
||||
func TextPreviewRenderKind(name string) string {
|
||||
if kind := TextPreviewKind(strings.ToLower(filepath.Ext(name))); kind != "" {
|
||||
return kind
|
||||
}
|
||||
for kind := range out {
|
||||
sort.Strings(out[kind])
|
||||
}
|
||||
return out
|
||||
}
|
||||
return "plain"
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package iteminfo
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,21 +21,16 @@ func TestTextPreviewKinds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsTextPreviewable(t *testing.T) {
|
||||
require.True(t, IsTextPreviewable("Notes.TXT"))
|
||||
require.True(t, IsTextPreviewable("dir/readme.md"))
|
||||
require.False(t, IsTextPreviewable("image.svg"))
|
||||
require.False(t, IsTextPreviewable("noext"))
|
||||
}
|
||||
|
||||
func TestTextPreviewExtensionsForSPA(t *testing.T) {
|
||||
groups := TextPreviewExtensionsForSPA()
|
||||
require.Contains(t, groups["markdown"], "md")
|
||||
require.Contains(t, groups["rtf"], "rtf")
|
||||
require.True(t, slices.Contains(groups["code"], "go"))
|
||||
require.False(t, slices.Contains(groups["code"], "svg"))
|
||||
for _, exts := range groups {
|
||||
for _, ext := range exts {
|
||||
require.NotContains(t, ext, ".", "extensions must not have a leading dot")
|
||||
}
|
||||
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
for _, mt := range []string{"", "image/svg+xml", "application/pdf", "application/json", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm"} {
|
||||
require.False(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextPreviewRenderKind(t *testing.T) {
|
||||
require.Equal(t, "markdown", TextPreviewRenderKind("notes.md"))
|
||||
require.Equal(t, "plain", TextPreviewRenderKind("50. Holy Water.lrc"))
|
||||
require.Equal(t, "plain", TextPreviewRenderKind("cert.pem"))
|
||||
}
|
||||
@@ -2886,7 +2886,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Text preview content",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/textpreview.Result"
|
||||
"$ref": "#/definitions/preview.TextPreviewResult"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -5274,7 +5274,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "Text preview content",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/textpreview.Result"
|
||||
"$ref": "#/definitions/preview.TextPreviewResult"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
@@ -6011,6 +6011,31 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview.TextPreviewResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content is plain text, or an HTML fragment when Format is \"html\".",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"description": "Format is \"text\" or \"html\". RTF is converted to HTML; everything else is text.",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"description": "Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "Size is the full size of the file in bytes.",
|
||||
"type": "integer"
|
||||
},
|
||||
"truncated": {
|
||||
"description": "Truncated is true when the file is larger than the returned content.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.ActivityConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8065,31 +8090,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"textpreview.Result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content is plain text, or an HTML fragment when Format is \"html\".",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"description": "Format is \"text\" or \"html\". RTF is converted to HTML; everything else is text.",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"description": "Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "Size is the full size of the file in bytes.",
|
||||
"type": "integer"
|
||||
},
|
||||
"truncated": {
|
||||
"description": "Truncated is true when the file is larger than the returned content.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -2875,7 +2875,7 @@
|
||||
"200": {
|
||||
"description": "Text preview content",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/textpreview.Result"
|
||||
"$ref": "#/definitions/preview.TextPreviewResult"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -5263,7 +5263,7 @@
|
||||
"200": {
|
||||
"description": "Text preview content",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/textpreview.Result"
|
||||
"$ref": "#/definitions/preview.TextPreviewResult"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
@@ -6000,6 +6000,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview.TextPreviewResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content is plain text, or an HTML fragment when Format is \"html\".",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"description": "Format is \"text\" or \"html\". RTF is converted to HTML; everything else is text.",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"description": "Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "Size is the full size of the file in bytes.",
|
||||
"type": "integer"
|
||||
},
|
||||
"truncated": {
|
||||
"description": "Truncated is true when the file is larger than the returned content.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.ActivityConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8054,31 +8079,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"textpreview.Result": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Content is plain text, or an HTML fragment when Format is \"html\".",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"description": "Format is \"text\" or \"html\". RTF is converted to HTML; everything else is text.",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"description": "Kind is the renderer the frontend should use: plain, csv, markdown, html, css, code, or rtf.",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "Size is the full size of the file in bytes.",
|
||||
"type": "integer"
|
||||
},
|
||||
"truncated": {
|
||||
"description": "Truncated is true when the file is larger than the returned content.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"users.FileLoading": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -260,6 +260,26 @@ definitions:
|
||||
description: release year
|
||||
type: integer
|
||||
type: object
|
||||
preview.TextPreviewResult:
|
||||
properties:
|
||||
content:
|
||||
description: Content is plain text, or an HTML fragment when Format is "html".
|
||||
type: string
|
||||
format:
|
||||
description: Format is "text" or "html". RTF is converted to HTML; everything
|
||||
else is text.
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is the renderer the frontend should use: plain, csv, markdown,
|
||||
html, css, code, or rtf.'
|
||||
type: string
|
||||
size:
|
||||
description: Size is the full size of the file in bytes.
|
||||
type: integer
|
||||
truncated:
|
||||
description: Truncated is true when the file is larger than the returned content.
|
||||
type: boolean
|
||||
type: object
|
||||
settings.ActivityConfig:
|
||||
properties:
|
||||
disabled:
|
||||
@@ -1786,26 +1806,6 @@ definitions:
|
||||
description: default view mode for anonymous users
|
||||
type: string
|
||||
type: object
|
||||
textpreview.Result:
|
||||
properties:
|
||||
content:
|
||||
description: Content is plain text, or an HTML fragment when Format is "html".
|
||||
type: string
|
||||
format:
|
||||
description: Format is "text" or "html". RTF is converted to HTML; everything
|
||||
else is text.
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is the renderer the frontend should use: plain, csv, markdown,
|
||||
html, css, code, or rtf.'
|
||||
type: string
|
||||
size:
|
||||
description: Size is the full size of the file in bytes.
|
||||
type: integer
|
||||
truncated:
|
||||
description: Truncated is true when the file is larger than the returned content.
|
||||
type: boolean
|
||||
type: object
|
||||
users.FileLoading:
|
||||
properties:
|
||||
clearAll:
|
||||
@@ -4514,7 +4514,7 @@ paths:
|
||||
"200":
|
||||
description: Text preview content
|
||||
schema:
|
||||
$ref: '#/definitions/textpreview.Result'
|
||||
$ref: '#/definitions/preview.TextPreviewResult'
|
||||
"400":
|
||||
description: Invalid request path
|
||||
schema:
|
||||
@@ -6181,7 +6181,7 @@ paths:
|
||||
"200":
|
||||
description: Text preview content
|
||||
schema:
|
||||
$ref: '#/definitions/textpreview.Result'
|
||||
$ref: '#/definitions/preview.TextPreviewResult'
|
||||
"403":
|
||||
description: Share unavailable, access denied, or the file viewer is disabled
|
||||
for this share
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
inset: 0;
|
||||
display: block;
|
||||
direction: ltr;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
/* The concrete fallback keeps the spinner visible before the app stylesheet loads,
|
||||
when neither --spinner-color nor --primaryColor is defined (initial index.html shell). */
|
||||
background-color: var(--spinner-color, var(--primaryColor, #2196f3));
|
||||
/* Mirrored annulus-sector polygon, 120° sweep, viewBox 0 0 100 100 */
|
||||
--fb-loader-taper-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Cpath%20fill%3D%22black%22%20d%3D%22M57.47%2C7.65%20L54.99%2C7.29%20L52.50%2C7.07%20L50.00%2C7.00%20L47.50%2C7.07%20L45.01%2C7.29%20L42.53%2C7.65%20L40.08%2C8.16%20L37.67%2C8.81%20L35.29%2C9.59%20L32.97%2C10.52%20L30.70%2C11.57%20L28.50%2C12.76%20L26.37%2C14.07%20L24.32%2C15.51%20L22.36%2C17.06%20L20.49%2C18.72%20L18.72%2C20.49%20L17.06%2C22.36%20L15.51%2C24.32%20L14.07%2C26.37%20L12.76%2C28.50%20L11.57%2C30.70%20L10.52%2C32.97%20L9.59%2C35.29%20L8.81%2C37.67%20L8.16%2C40.08%20L7.65%2C42.53%20L7.29%2C45.01%20L7.07%2C47.50%20L7.00%2C50.00%20L7.07%2C52.50%20L7.29%2C54.99%20L7.65%2C57.47%20L8.16%2C59.92%20L8.81%2C62.33%20L9.59%2C64.71%20L10.16%2C64.50%20L9.56%2C62.11%20L9.12%2C59.69%20L8.81%2C57.26%20L8.65%2C54.83%20L8.63%2C52.41%20L8.75%2C50.00%20L9.01%2C47.61%20L9.41%2C45.26%20L9.94%2C42.94%20L10.61%2C40.66%20L11.40%2C38.44%20L12.32%2C36.28%20L13.36%2C34.19%20L14.51%2C32.18%20L15.77%2C30.24%20L17.14%2C28.39%20L18.60%2C26.63%20L20.16%2C24.96%20L21.81%2C23.40%20L23.53%2C21.95%20L25.33%2C20.60%20L27.20%2C19.37%20L29.12%2C18.26%20L31.10%2C17.26%20L33.12%2C16.39%20L35.18%2C15.64%20L37.27%2C15.02%20L39.38%2C14.52%20L41.50%2C14.15%20L43.64%2C13.91%20L45.77%2C13.79%20L47.89%2C13.79%20L50.00%2C13.92%20L52.09%2C14.18%20L54.14%2C14.55%20L56.16%2C15.04Z%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
@@ -143,7 +145,7 @@ body > .loader.done {
|
||||
|
||||
.loader--pulse {
|
||||
border: none;
|
||||
background: var(--spinner-color, var(--primaryColor));
|
||||
background: var(--spinner-color, var(--primaryColor, #2196f3));
|
||||
animation: fb-pulse-scale var(--animation-duration, 0.75s) ease infinite;
|
||||
}
|
||||
|
||||
@@ -178,7 +180,9 @@ body > .loader.done {
|
||||
content: "";
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
/* The concrete fallback keeps the spinner visible before the app stylesheet loads,
|
||||
when neither --spinner-color nor --primaryColor is defined (initial index.html shell). */
|
||||
background-color: var(--spinner-color, var(--primaryColor, #2196f3));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
@@ -195,7 +199,9 @@ body > .loader.done {
|
||||
.loader--bounce .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
/* The concrete fallback keeps the spinner visible before the app stylesheet loads,
|
||||
when neither --spinner-color nor --primaryColor is defined (initial index.html shell). */
|
||||
background-color: var(--spinner-color, var(--primaryColor, #2196f3));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
@@ -355,3 +361,20 @@ body > .loader.done {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Reduced motion */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* Honor the OS "reduce motion" preference: stop the rotation, pulse, bounce,
|
||||
and placeholder shimmer. The spinner stays visible as a static shape. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loader__comet,
|
||||
.loader--pulse,
|
||||
.loader--bounce::before,
|
||||
.loader--bounce::after,
|
||||
.loader--bounce .bounce-dot,
|
||||
.placeholder-item {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>"{{ .htmlVars.title }}"</title>
|
||||
<title>{{ .htmlVars.title }}</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
|
||||
<link rel="stylesheet" href="{{ .htmlVars.staticURL }}/css/variables.css">
|
||||
@@ -82,7 +82,9 @@
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
|
||||
<span class="loader loader--medium" role="status" aria-live="polite" aria-label="Loading">
|
||||
<span class="loader__comet" aria-hidden="true"></span>
|
||||
</span>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
|
||||
@@ -1207,6 +1207,28 @@ export function getPreviewURL(source, path, modified) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a text preview API URL. Use fetchTextPreview(url, modified) to load it.
|
||||
* Separate from getPreviewURL: the text endpoint returns JSON, not an image.
|
||||
*/
|
||||
export function getTextPreviewURL(source, path) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
source: source,
|
||||
inline: 'true'
|
||||
}
|
||||
const apiPath = getApiPath('resources/preview/text', params)
|
||||
return window.origin + apiPath
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error getting text preview URL')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/resources/archive - Create an archive
|
||||
export async function createArchive(opts) {
|
||||
const { fromSource, toSource, paths, destination, format, compression, deleteAfter } = opts;
|
||||
@@ -1359,6 +1381,28 @@ export function getPreviewURLPublic(path, size, viewToken = state.req?.viewToken
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a text preview URL for public shares
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} [viewToken]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getTextPreviewURLPublic(path, viewToken = state.req?.viewToken) {
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
hash: state.shareInfo.hash,
|
||||
inline: 'true',
|
||||
...(viewToken && { viewToken }),
|
||||
}
|
||||
const apiPath = getPublicApiPath('resources/preview/text', params)
|
||||
return window.origin + apiPath
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || 'Error getting text preview URL')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file, one chunk of it, or creates a folder.
|
||||
* @param {string | undefined} hash
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
const { mockState, mockGetters, setPopupPreview } = vi.hoisted(() => ({
|
||||
mockState: {
|
||||
sessionId: "test-session",
|
||||
user: {},
|
||||
shareInfo: null,
|
||||
req: { source: "main", path: "/", modified: "" },
|
||||
popupPreviewSourceInfo: null,
|
||||
},
|
||||
mockGetters: {
|
||||
sourcePermissions: () => ({ view: true }),
|
||||
previewPerms: () => ({ office: true, popup: true, image: true, video: true, audio: true, models: true, folder: true }),
|
||||
isShare: () => false,
|
||||
viewMode: () => "gallery",
|
||||
isDarkMode: () => false,
|
||||
},
|
||||
setPopupPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
state: mockState,
|
||||
getters: mockGetters,
|
||||
mutations: { setPopupPreview, setPreviewSource: vi.fn() },
|
||||
}));
|
||||
|
||||
// The real TextPreview is async and pulls in highlight.js; a stub is enough to detect mounting.
|
||||
vi.mock("@/utils/asyncComponent.js", () => ({
|
||||
createAsyncComponent: () => ({
|
||||
name: "TextPreviewStub",
|
||||
render: () => h("div", { class: "text-preview" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
type ResizeCb = (entries: ResizeObserverEntry[]) => void;
|
||||
let resizeCallback: ResizeCb | null = null;
|
||||
|
||||
interface IconProps {
|
||||
filename?: string;
|
||||
mimetype?: string;
|
||||
hasPreview?: boolean;
|
||||
path?: string;
|
||||
source?: string;
|
||||
textPreviewUrl?: string;
|
||||
}
|
||||
|
||||
function mountIcon(props: IconProps = {}) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h(Icon, {
|
||||
filename: "notes.md",
|
||||
mimetype: "text/markdown",
|
||||
hasPreview: true,
|
||||
path: "/notes.md",
|
||||
source: "main",
|
||||
textPreviewUrl: "/api/resources/preview/text?path=%2Fnotes.md",
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("Icon text preview width gate", () => {
|
||||
beforeEach(() => {
|
||||
setPopupPreview.mockClear();
|
||||
resizeCallback = null;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(cb: ResizeCb) {
|
||||
resizeCallback = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("shows the type icon plus a preview-available badge on a small tile", async () => {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
|
||||
expect(el.querySelector(".text-preview")).toBeNull();
|
||||
const hint = el.querySelector(".overlay-icon--preview-hint");
|
||||
expect(hint).not.toBeNull();
|
||||
expect(hint?.textContent).toContain("saved_search");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("hides the badge when popup previews are disabled", async () => {
|
||||
const original = mockGetters.previewPerms;
|
||||
mockGetters.previewPerms = () => ({
|
||||
office: true,
|
||||
popup: false,
|
||||
image: true,
|
||||
video: true,
|
||||
audio: true,
|
||||
models: true,
|
||||
folder: true,
|
||||
});
|
||||
try {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
expect(el.querySelector(".overlay-icon--preview-hint")).toBeNull();
|
||||
app.unmount();
|
||||
} finally {
|
||||
mockGetters.previewPerms = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the hover popup preview working on a small tile", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
|
||||
el.querySelector(".image-preview")?.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(setPopupPreview).toHaveBeenCalledTimes(1);
|
||||
expect(setPopupPreview.mock.calls[0]?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
path: "/notes.md",
|
||||
textUrl: "/api/resources/preview/text?path=%2Fnotes.md",
|
||||
});
|
||||
vi.useRealTimers();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("mounts TextPreview once the tile is wide enough", async () => {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
expect(el.querySelector(".text-preview")).toBeNull();
|
||||
|
||||
// Simulate the ResizeObserver reporting a gallery-sized tile.
|
||||
resizeCallback?.([{ contentRect: { width: 400 } } as ResizeObserverEntry]);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(el.querySelector(".text-preview")).not.toBeNull();
|
||||
expect(el.querySelector(".overlay-icon--preview-hint")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Unified preview container for all types -->
|
||||
<span v-if="hasPreviewImage || (shouldUse3DPreview && !threeJsError) || shouldUseTextPreview" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<span v-if="hasPreviewImage || (shouldUse3DPreview && !threeJsError) || shouldUseTextPreview" ref="tileEl" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<!-- Overlay icons (folder/animation) positioned top-left -->
|
||||
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-symbols-outlined overlay-icon">animation</i>
|
||||
<i v-else-if="hasPreviewImage && !isFile" class="material-symbols overlay-icon">folder</i>
|
||||
@@ -21,18 +21,26 @@
|
||||
@error="handle3DError" />
|
||||
<TextPreview v-else-if="shouldUseTextPreview"
|
||||
:url="textPreviewUrl"
|
||||
:language="textPreviewLanguage"
|
||||
:modified="modified"
|
||||
@error="handleTextPreviewError" />
|
||||
</span>
|
||||
<!-- Regular material icon (no preview) -->
|
||||
<span v-else class="image-preview">
|
||||
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
|
||||
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialSymbol }} </i>
|
||||
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
|
||||
<!-- Text file with a popup preview, but the tile is too small to render it inline -->
|
||||
<i
|
||||
v-if="textPreviewHintVisible"
|
||||
class="material-symbols overlay-icon overlay-icon--preview-hint"
|
||||
:class="{ 'overlay-icon--preview-hint-shared': isShared }"
|
||||
aria-hidden="true"
|
||||
>saved_search</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, ref, unref, watch, type Ref } from "vue";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources";
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
@@ -45,8 +53,7 @@ import {
|
||||
storeThumbnailBlob,
|
||||
} from "@/utils/cache/imageCache.ts";
|
||||
import { queuedFetchPreview } from "@/utils/previewRequests.ts";
|
||||
import { textPreviewUrlFor } from "@/utils/textPreview.ts";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { isTextPreviewableType, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -64,6 +71,8 @@ const props = withDefaults(
|
||||
isDir?: boolean;
|
||||
viewToken?: string;
|
||||
inView?: boolean;
|
||||
/** Text preview API URL from getTextPreviewURL / getTextPreviewURLPublic. */
|
||||
textPreviewUrl?: string;
|
||||
}>(),
|
||||
{
|
||||
hasPreview: false,
|
||||
@@ -78,6 +87,7 @@ const props = withDefaults(
|
||||
isDir: false,
|
||||
viewToken: "",
|
||||
inView: true,
|
||||
textPreviewUrl: "",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -214,29 +224,57 @@ const shouldUse3DPreview = computed(() => {
|
||||
return typeInfo.value.simpleType === "3d-model";
|
||||
});
|
||||
|
||||
/** Every extension the text preview endpoint supports, from the backend's kind map. */
|
||||
const textPreviewExtSet = computed(() => {
|
||||
const groups = (globalVars.textPreviewExtensions ?? {}) as Record<string, string[]>;
|
||||
return new Set(Object.values(groups).flat());
|
||||
});
|
||||
/** True when the filename has an extension the text preview endpoint supports. */
|
||||
const isTextPreviewable = computed(
|
||||
() => props.mimetype !== "directory" && isTextPreviewableType(props.mimetype),
|
||||
);
|
||||
|
||||
const isTextPreviewable = computed(() => {
|
||||
if (props.mimetype === "directory" || !props.filename) return false;
|
||||
const dot = props.filename.lastIndexOf(".");
|
||||
if (dot <= 0) return false;
|
||||
return textPreviewExtSet.value.has(props.filename.slice(dot + 1).toLowerCase());
|
||||
});
|
||||
/** Extension used for syntax highlighting and the CSV/TSV delimiter. */
|
||||
const textPreviewLanguage = computed(() => languageFromPath(props.filename));
|
||||
|
||||
const textPreviewUrl = computed(() => (props.thumbnailUrl ? textPreviewUrlFor(props.thumbnailUrl) : ""));
|
||||
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
|
||||
const tileWidth = ref(0);
|
||||
const tileEl = ref<HTMLElement | null>(null);
|
||||
let tileResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const shouldUseTextPreview = computed(() => {
|
||||
function observeTileWidth(el: HTMLElement | null): void {
|
||||
tileResizeObserver?.disconnect();
|
||||
tileResizeObserver = null;
|
||||
if (!el || !isTextPreviewable.value || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
// ResizeObserver fires once on observe, so no manual initial measurement is needed.
|
||||
tileResizeObserver = new ResizeObserver((entries) => {
|
||||
tileWidth.value = entries[0]?.contentRect.width ?? 0;
|
||||
});
|
||||
tileResizeObserver.observe(el);
|
||||
}
|
||||
|
||||
// The tile element switches between the icon span and the preview span, so re-observe it.
|
||||
watch(tileEl, observeTileWidth, { flush: "post" });
|
||||
|
||||
/** True when the tile is wide enough to render readable text instead of an icon. */
|
||||
const tileWideEnough = computed(() => tileWidth.value >= MIN_TEXT_PREVIEW_WIDTH_PX);
|
||||
|
||||
/**
|
||||
* True when this item has a text preview to show (inline or via the hover popup).
|
||||
* Kept separate from shouldUseTextPreview so the popup still works on small tiles.
|
||||
*/
|
||||
const hasTextPreview = computed(() => {
|
||||
if (!canViewSource.value || !isTextPreviewable.value || !props.hasPreview || !everInView.value) return false;
|
||||
if (textPreviewError.value || props.thumbnailUrl === "") return false;
|
||||
if (textPreviewError.value || props.textPreviewUrl === "") return false;
|
||||
if (state.shareInfo?.disableThumbnails || previewExtDisabled.value) return false;
|
||||
// Same permission as other document-style previews (txt/md/html were already gated by office).
|
||||
return getters.previewPerms().office;
|
||||
});
|
||||
|
||||
const shouldUseTextPreview = computed(() => hasTextPreview.value && tileWideEnough.value);
|
||||
|
||||
/** Show the popup-preview badge on tiles too small for the inline text preview. */
|
||||
const textPreviewHintVisible = computed(
|
||||
() => hasTextPreview.value && !shouldUseTextPreview.value && !hasPreviewImage.value && getters.previewPerms().popup,
|
||||
);
|
||||
|
||||
function handleTextPreviewError(): void {
|
||||
textPreviewError.value = true;
|
||||
}
|
||||
@@ -391,13 +429,14 @@ function showPopupPreview(): void {
|
||||
const source = (getters.isShare() ? state.shareInfo?.hash : props.source || state.req?.source) ?? "";
|
||||
const modified = props.modified || state.req?.modified;
|
||||
|
||||
// Text-like file: popup shows the same text preview tile at a larger fixed size
|
||||
if (shouldUseTextPreview.value) {
|
||||
// Text-like file: popup shows the same text preview tile at a larger fixed size.
|
||||
// Uses hasTextPreview, not shouldUseTextPreview, so small tiles still get a popup.
|
||||
if (hasTextPreview.value) {
|
||||
mutations.setPopupPreview({
|
||||
type: "text",
|
||||
source,
|
||||
path,
|
||||
textUrl: textPreviewUrl.value,
|
||||
textUrl: props.textPreviewUrl,
|
||||
modified,
|
||||
});
|
||||
return;
|
||||
@@ -526,7 +565,14 @@ watch(
|
||||
{ immediate: true }, // Run on component mount
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Only text previews need the measured width, so image tiles never create an observer.
|
||||
observeTileWidth(tileEl.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
tileResizeObserver?.disconnect();
|
||||
tileResizeObserver = null;
|
||||
cancelActivePreviews();
|
||||
if (props.path && state.popupPreviewSourceInfo?.path === props.path) {
|
||||
mutations.setPopupPreview(null);
|
||||
@@ -563,6 +609,19 @@ onBeforeUnmount(() => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* "Preview available in popup" badge on tiles too small to render the text itself.
|
||||
Top-left, matching the other overlay icons; offset when the shared badge shares that corner. */
|
||||
.overlay-icon--preview-hint {
|
||||
inset: 0.2em auto auto 0.2em;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-icon--preview-hint-shared {
|
||||
inset: 0.2em auto auto 2.2em;
|
||||
}
|
||||
|
||||
.overlay-icon--bottom.clickable:hover {
|
||||
transform: none;
|
||||
box-shadow: none !important;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
:size="size"
|
||||
:isShared="isShared"
|
||||
:viewToken="viewToken"
|
||||
:textPreviewUrl="textPreviewUrl"
|
||||
/>
|
||||
<button
|
||||
v-if="quickDownloadEnabled && galleryView"
|
||||
@@ -97,6 +98,7 @@ import { globalVars } from "@/utils/constants";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getKindKey } from "@/utils/mimetype";
|
||||
import { isTextPreviewableType } from "@/utils/textPreview";
|
||||
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { formatDuration } from "@/utils/files.js";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
@@ -373,6 +375,35 @@ export default {
|
||||
}
|
||||
return resourcesApi.getPreviewURL(this.source || state.req?.source, previewPath, this.modified);
|
||||
},
|
||||
textPreviewUrl() {
|
||||
// The text preview endpoint is JSON, not an image, so it needs its own URL.
|
||||
// Reuse the same path resolution as thumbnailUrl so duplicates/FileTree work.
|
||||
if (!globalVars.enableThumbs) {
|
||||
return "";
|
||||
}
|
||||
// Only text-previewable names have a preview; other items must never build a URL
|
||||
// (a DuplicateFinder row can pass a text-like name with no source).
|
||||
if (!isTextPreviewableType(this.type)) {
|
||||
return "";
|
||||
}
|
||||
let previewPath;
|
||||
if (this.path) {
|
||||
previewPath = this.path;
|
||||
} else if (state.req?.path && this.name) {
|
||||
previewPath = url.joinPath(state.req.path, this.name);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
// getTextPreviewURL throws without a source; return "" so the icon falls back.
|
||||
const source = this.source || state.req?.source;
|
||||
if (this.forceFilesApi) {
|
||||
return source ? resourcesApi.getTextPreviewURL(source, previewPath) : "";
|
||||
}
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getTextPreviewURLPublic(previewPath);
|
||||
}
|
||||
return source ? resourcesApi.getTextPreviewURL(source, previewPath) : "";
|
||||
},
|
||||
isClickable() {
|
||||
return this.clickable;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
getters: { isDarkMode: () => false },
|
||||
state: { sessionId: "test-session" },
|
||||
}));
|
||||
|
||||
import TextPreview from "./TextPreview.vue";
|
||||
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
|
||||
|
||||
function payload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: "",
|
||||
kind: "plain",
|
||||
format: "text",
|
||||
truncated: false,
|
||||
size: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(data: unknown) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
// Wait for the fetch promise chain and queued limiter to settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({ render: () => h(TextPreview, props) });
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("TextPreview rendering", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("does not render a truncation fade for a truncated payload", async () => {
|
||||
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
|
||||
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
|
||||
expect(el.querySelector(".text-preview-fade")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("uses the language prop to pick the CSV delimiter", async () => {
|
||||
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
|
||||
const { el, app } = mount({
|
||||
url: "/api/resources/preview/text?path=%2Fdata.tsv",
|
||||
modified: "1",
|
||||
language: "tsv",
|
||||
});
|
||||
await flush();
|
||||
|
||||
const cells = el.querySelectorAll(".text-preview-table thead th");
|
||||
expect(cells).toHaveLength(2);
|
||||
expect(cells[0]?.textContent).toBe("a");
|
||||
expect(cells[1]?.textContent).toBe("b");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the rendered HTML cache for the same url and modified time", async () => {
|
||||
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
|
||||
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
|
||||
|
||||
const first = mount(props);
|
||||
await flush();
|
||||
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
first.app.unmount();
|
||||
|
||||
// A second mount (as happens when the popup reopens) must not re-fetch.
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
fetchMock.mockClear();
|
||||
const second = mount(props);
|
||||
await flush();
|
||||
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
second.app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div ref="root" class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
|
||||
<span v-if="status === 'loading'" class="material-symbols text-preview-placeholder">{{ PLACEHOLDER_ICON }}</span>
|
||||
<!-- Small tiles: markdown/html cannot fit, so show the readable text only -->
|
||||
<pre v-else-if="status === 'ready' && compactText !== null" class="text-preview-body text-preview-plain text-preview-compact">{{ compactText }}</pre>
|
||||
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap" :class="{ 'text-preview-compact': compact }">
|
||||
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap">
|
||||
<table class="text-preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -17,39 +15,42 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" :class="{ 'text-preview-compact': compact }" v-html="renderedHtml"></div>
|
||||
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" :class="{ 'text-preview-compact': compact }" v-html="highlighted"></pre>
|
||||
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain" :class="{ 'text-preview-compact': compact }">{{ payload.content }}</pre>
|
||||
<div v-if="status === 'ready' && payload?.truncated" class="text-preview-fade"></div>
|
||||
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" v-html="renderedHtml"></div>
|
||||
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" v-html="highlighted"></pre>
|
||||
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain">{{ payload.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { getters } from "@/store";
|
||||
import { applyHighlightTheme, loadHljs } from "@/utils/highlight.ts";
|
||||
import { parseDelimited } from "@/utils/delimited.ts";
|
||||
import { fetchTextPreview, languageFromPreviewUrl } from "@/utils/textPreview.ts";
|
||||
import {
|
||||
plainTextFromHtml,
|
||||
fetchTextPreview,
|
||||
getRenderedTextPreview,
|
||||
languageFromPreviewUrl,
|
||||
releaseTextPreview,
|
||||
setRenderedTextPreview,
|
||||
textPreviewCacheKey,
|
||||
} from "@/utils/textPreview.ts";
|
||||
import {
|
||||
renderMarkdownTile,
|
||||
sanitizeTileHtml,
|
||||
type TextPreviewPayload,
|
||||
} from "@/utils/textPreviewRender.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Text preview API URL (see textPreviewUrlFor). */
|
||||
/** Text preview API URL (see getTextPreviewURL / getTextPreviewURLPublic). */
|
||||
url: string;
|
||||
/** Modified time, used with the URL as the cache key. */
|
||||
modified?: string | null;
|
||||
/** Lowercase extension without the dot; derived from the URL when omitted. */
|
||||
language?: string | null;
|
||||
}>();
|
||||
|
||||
|
||||
const emit = defineEmits<(e: "error") => void>();
|
||||
|
||||
|
||||
// Tiles narrower than this cannot fit rendered markdown or HTML, so they show plain text.
|
||||
const COMPACT_WIDTH_PX = 240;
|
||||
// Only plain source files are syntax highlighted. Markdown and HTML are rendered instead.
|
||||
const HIGHLIGHT_KINDS = new Set(["code", "css"]);
|
||||
// Limits keep the DOM small; the tile only shows its top-left area anyway.
|
||||
@@ -64,37 +65,30 @@ const status = ref<Status>("loading");
|
||||
const payload = shallowRef<TextPreviewPayload | null>(null);
|
||||
let requestId = 0;
|
||||
|
||||
/** Cache key shared with the tile and the popup, so rendering happens once per file. */
|
||||
const cacheKey = computed(() => textPreviewCacheKey(props.url, props.modified ?? null));
|
||||
|
||||
/** Language for highlighting/CSV, preferring the caller-provided extension. */
|
||||
const language = computed(() => props.language ?? languageFromPreviewUrl(props.url));
|
||||
|
||||
// Only markdown, html, and rtf render as HTML. Everything else (plain, css, code) is shown as escaped text.
|
||||
// Rendered HTML is cached so scroll-back and hover do not re-parse and re-sanitize.
|
||||
const renderedHtml = computed(() => {
|
||||
const data = payload.value;
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
if (data.kind === "markdown") {
|
||||
return renderMarkdownTile(data.content);
|
||||
if (data.kind !== "markdown" && data.kind !== "html" && data.format !== "html") {
|
||||
return "";
|
||||
}
|
||||
if (data.kind === "html" || data.format === "html") {
|
||||
return sanitizeTileHtml(data.content);
|
||||
const key = cacheKey.value;
|
||||
const cached = getRenderedTextPreview(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const compact = ref(false);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Plain text shown on compact tiles. Markdown is shown as its source, HTML as its visible text.
|
||||
const compactText = computed<string | null>(() => {
|
||||
const data = payload.value;
|
||||
// CSV keeps its table, HTML keeps its rendering, and code/css keep their highlighting on compact tiles.
|
||||
if (!compact.value || !data || data.kind === "csv" || data.kind === "html" || HIGHLIGHT_KINDS.has(data.kind)) {
|
||||
return null;
|
||||
}
|
||||
// Converted RTF is HTML too, so it still falls back to text on compact tiles.
|
||||
if (data.format === "html") {
|
||||
return plainTextFromHtml(sanitizeTileHtml(data.content));
|
||||
}
|
||||
return data.content;
|
||||
const html = data.kind === "markdown" ? renderMarkdownTile(data.content) : sanitizeTileHtml(data.content);
|
||||
setRenderedTextPreview(key, html);
|
||||
return html;
|
||||
});
|
||||
|
||||
// CSV/TSV as a table: first row is the header. A truncated file may end mid-row, so that row is dropped.
|
||||
@@ -103,7 +97,7 @@ const csvTable = computed<{ header: string[]; body: string[][] } | null>(() => {
|
||||
if (data?.kind !== "csv") {
|
||||
return null;
|
||||
}
|
||||
const delimiter = languageFromPreviewUrl(props.url) === "tsv" ? "\t" : ",";
|
||||
const delimiter = language.value === "tsv" ? "\t" : ",";
|
||||
let rows = parseDelimited(data.content, delimiter);
|
||||
if (data.truncated && rows.length > 1) {
|
||||
rows = rows.slice(0, -1);
|
||||
@@ -122,48 +116,59 @@ let highlightId = 0;
|
||||
|
||||
async function updateHighlight(): Promise<void> {
|
||||
const token = ++highlightId;
|
||||
highlighted.value = null;
|
||||
const data = payload.value;
|
||||
if (!data || !HIGHLIGHT_KINDS.has(data.kind)) {
|
||||
highlighted.value = null;
|
||||
return;
|
||||
}
|
||||
const language = languageFromPreviewUrl(props.url);
|
||||
if (!language) {
|
||||
const lang = language.value;
|
||||
if (!lang) {
|
||||
highlighted.value = null;
|
||||
return;
|
||||
}
|
||||
// Highlighted output is theme-independent, so it can be cached and reused.
|
||||
const cacheKeyWithLang = `${cacheKey.value}|hl`;
|
||||
const cached = getRenderedTextPreview(cacheKeyWithLang);
|
||||
if (cached !== undefined) {
|
||||
// The cached markup is theme-independent, but the highlight.js style element is not,
|
||||
// so the theme must still be applied on a theme switch or when a tile mounts from cache.
|
||||
await applyHighlightTheme(isDark.value);
|
||||
if (token === highlightId) {
|
||||
highlighted.value = cached;
|
||||
}
|
||||
return;
|
||||
}
|
||||
highlighted.value = null;
|
||||
try {
|
||||
const hljs = await loadHljs();
|
||||
if (!hljs.getLanguage(language)) {
|
||||
if (!hljs.getLanguage(lang)) {
|
||||
return; // unknown language: keep the plain text
|
||||
}
|
||||
await applyHighlightTheme(isDark.value);
|
||||
const html = hljs.highlight(data.content, { language, ignoreIllegals: true }).value;
|
||||
const html = hljs.highlight(data.content, { language: lang, ignoreIllegals: true }).value;
|
||||
if (token === highlightId) {
|
||||
// highlight.js escapes its input; sanitizing again is cheap defense in depth.
|
||||
highlighted.value = sanitizeTileHtml(html);
|
||||
const safe = sanitizeTileHtml(html);
|
||||
setRenderedTextPreview(cacheKeyWithLang, safe);
|
||||
highlighted.value = safe;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to highlight text preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
watch([payload, compact, isDark], () => {
|
||||
watch([payload, language, isDark], () => {
|
||||
void updateHighlight();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (root.value && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0;
|
||||
compact.value = width > 0 && width < COMPACT_WIDTH_PX;
|
||||
});
|
||||
resizeObserver.observe(root.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.url, () => props.modified],
|
||||
async ([url, modified]) => {
|
||||
async ([url, modified], previous) => {
|
||||
// Release the reference held for the previous url/modified pair (if any) so its
|
||||
// queued request can be canceled once no mounted consumer still needs it.
|
||||
if (previous) {
|
||||
releaseTextPreview(previous[0] as string, (previous[1] as string | null) ?? null);
|
||||
}
|
||||
const current = ++requestId;
|
||||
status.value = "loading";
|
||||
payload.value = null;
|
||||
@@ -182,11 +187,12 @@ watch(
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Drop any in-flight result so it never updates an unmounted tile.
|
||||
// Drop any in-flight result so it never updates an unmounted tile, and release the
|
||||
// request reference so queued previews for unmounted tiles stop competing with the
|
||||
// folder the user is actually viewing.
|
||||
requestId++;
|
||||
highlightId++;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
releaseTextPreview(props.url, props.modified ?? null);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -237,7 +243,11 @@ onBeforeUnmount(() => {
|
||||
padding: 0.6em;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 3.4cqw;
|
||||
/* Lay out at a readable width and let the tile clip, instead of wrapping aggressively */
|
||||
min-width: var(--text-preview-min-width, 320px);
|
||||
/* Scale with the tile, but never below a legible floor: at the 60px inline threshold
|
||||
3.4cqw is only ~2px, so the floor keeps the smallest tiles readable. */
|
||||
font-size: max(3.4cqw, 8px);
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -274,12 +284,6 @@ onBeforeUnmount(() => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Compact tiles: keep text at a readable minimum size and let the tile clip the rest */
|
||||
.text-preview-body.text-preview-compact {
|
||||
font-size: max(6px, 3.4cqw);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.text-preview-html :deep(h1),
|
||||
.text-preview-html :deep(h2),
|
||||
.text-preview-html :deep(h3),
|
||||
@@ -352,14 +356,4 @@ onBeforeUnmount(() => {
|
||||
color: var(--primaryColor);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-preview-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(to bottom, transparent, var(--surfacePrimary));
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -83,7 +83,7 @@ import { useListingVirtualization } from "@/composables/useListingVirtualization
|
||||
import url from "@/utils/url";
|
||||
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
|
||||
|
||||
interface DeleteItem {
|
||||
export interface DeleteItem {
|
||||
source?: string;
|
||||
path: string;
|
||||
type?: string;
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"replay_10",
|
||||
"restart",
|
||||
"save",
|
||||
"saved_search",
|
||||
"search",
|
||||
"search_off",
|
||||
"select_all",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearTextPreviewCache, fetchTextPreview, languageFromPreviewUrl, textPreviewUrlFor } from "./textPreview.ts";
|
||||
import {
|
||||
clearTextPreviewCache,
|
||||
fetchTextPreview,
|
||||
getRenderedTextPreview,
|
||||
isTextPreviewableType,
|
||||
languageFromPath,
|
||||
languageFromPreviewUrl,
|
||||
MIN_TEXT_PREVIEW_WIDTH_PX,
|
||||
releaseTextPreview,
|
||||
setRenderedTextPreview,
|
||||
textPreviewCacheKey,
|
||||
} from "./textPreview.ts";
|
||||
|
||||
describe("languageFromPreviewUrl", () => {
|
||||
it("reads the lowercase extension from the path parameter", () => {
|
||||
@@ -12,17 +23,53 @@ describe("languageFromPreviewUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("textPreviewUrlFor", () => {
|
||||
it("maps the authenticated preview URL to the text endpoint and keeps params", () => {
|
||||
const thumb = "http://localhost/api/resources/preview?path=%2Fa.md&key=123&source=main&inline=true";
|
||||
expect(textPreviewUrlFor(thumb)).toBe(
|
||||
"http://localhost/api/resources/preview/text?path=%2Fa.md&key=123&source=main&inline=true",
|
||||
);
|
||||
describe("languageFromPath", () => {
|
||||
it("derives the extension without parsing a URL", () => {
|
||||
expect(languageFromPath("/src/Main.GO")).toBe("go");
|
||||
expect(languageFromPath("data.TSV")).toBe("tsv");
|
||||
expect(languageFromPath("Makefile")).toBeNull();
|
||||
expect(languageFromPath(".env")).toBeNull();
|
||||
expect(languageFromPath(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTextPreviewableType", () => {
|
||||
it("is true for text/* mimetypes, with or without parameters", () => {
|
||||
for (const mt of ["text/markdown", "text/lyrics", "text/plain; charset=utf-8", "text/x-go"]) {
|
||||
expect(isTextPreviewableType(mt), mt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the public share preview URL and keeps the hash", () => {
|
||||
const thumb = "http://localhost/public/api/resources/preview?path=%2Fa.txt&hash=abc";
|
||||
expect(textPreviewUrlFor(thumb)).toBe("http://localhost/public/api/resources/preview/text?path=%2Fa.txt&hash=abc");
|
||||
it("is false for non-text mimetypes and missing types", () => {
|
||||
for (const mt of ["application/x-sqlite3", "application/postscript", "application/wasm", "application/backup", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
|
||||
expect(isTextPreviewableType(mt), String(mt)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_TEXT_PREVIEW_WIDTH_PX", () => {
|
||||
it("is the tile width below which no inline preview is rendered", () => {
|
||||
expect(MIN_TEXT_PREVIEW_WIDTH_PX).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered HTML cache", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
});
|
||||
|
||||
it("round-trips a value and marks it most recently used", () => {
|
||||
const key = textPreviewCacheKey("/api/resources/preview/text?path=a", "2026-01-01");
|
||||
setRenderedTextPreview(key, "<p>hi</p>");
|
||||
expect(getRenderedTextPreview(key)).toBe("<p>hi</p>");
|
||||
});
|
||||
|
||||
it("keys by URL and modified time", () => {
|
||||
const a = textPreviewCacheKey("/u", "1");
|
||||
const b = textPreviewCacheKey("/u", "2");
|
||||
expect(a).not.toBe(b);
|
||||
setRenderedTextPreview(a, "<p>a</p>");
|
||||
expect(getRenderedTextPreview(b)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,3 +104,41 @@ describe("fetchTextPreview cache", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseTextPreview", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps a settled result after the last consumer releases", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ content: "x", kind: "plain", format: "text", truncated: false, size: 1 }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchTextPreview("/u", "1");
|
||||
// The popup/tile unmounts after the fetch resolved.
|
||||
releaseTextPreview("/u", "1");
|
||||
|
||||
// Reopening must reuse the cached result, not fetch again.
|
||||
await fetchTextPreview("/u", "1");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts a pending request when its last consumer unmounts", async () => {
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
// Never resolves on its own; only aborts when the consumer goes away.
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
observedSignal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const pending = fetchTextPreview("/u", "1");
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
releaseTextPreview("/u", "1");
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
await expect(pending).rejects.toThrow(/Aborted/);
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,9 @@ import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
|
||||
// Text previews are small (about 16KB), so the frontend keeps a bounded in-memory cache.
|
||||
// Each key includes the modified time, so a changed file is fetched again on next view.
|
||||
const MAX_CACHE_ENTRIES = 200;
|
||||
const cache = new Map<string, Promise<TextPreviewPayload>>();
|
||||
|
||||
/**
|
||||
* Derives the text preview URL from an image preview thumbnail URL. Works for both
|
||||
* /api/resources/preview and /public/api/resources/preview, so auth and share params carry over.
|
||||
*/
|
||||
export function textPreviewUrlFor(thumbnailUrl: string): string {
|
||||
return thumbnailUrl.replace(/\/resources\/preview\?/, "/resources/preview/text?");
|
||||
}
|
||||
/** Tiles narrower than this show an indicator instead of rendered markdown/HTML/CSV. */
|
||||
export const MIN_TEXT_PREVIEW_WIDTH_PX = 60;
|
||||
|
||||
/**
|
||||
* Lowercase file extension (no dot) of the file a text preview URL points at, or null.
|
||||
@@ -20,54 +14,213 @@ export function textPreviewUrlFor(thumbnailUrl: string): string {
|
||||
*/
|
||||
export function languageFromPreviewUrl(url: string): string | null {
|
||||
const path = new URL(url, window.location.href).searchParams.get("path") ?? "";
|
||||
return languageFromPath(path);
|
||||
}
|
||||
|
||||
/** Lowercase file extension (no dot) of a path, or null. A leading dot marks a dotfile. */
|
||||
export function languageFromPath(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
// A leading dot marks a dotfile (".env"), which has no extension.
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
return name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
async function requestTextPreview(url: string): Promise<TextPreviewPayload> {
|
||||
const res = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: { sessionId: state.sessionId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Text preview request failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as TextPreviewPayload;
|
||||
/**
|
||||
* True when an item's mimetype is text/*. The backend uses the same rule, so an item the
|
||||
* listing marks as text is the one the text preview endpoint renders. Binary types such as
|
||||
* sqlite, postscript, wasm and backup are not text/* and never reach the endpoint.
|
||||
*/
|
||||
export function isTextPreviewableType(mimetype: string | null | undefined): boolean {
|
||||
return (mimetype ?? "").startsWith("text/");
|
||||
}
|
||||
|
||||
// Text fetches are queued so a folder full of text files does not fire dozens of
|
||||
// requests at once. Mirrors previewRequests.ts, but returns parsed JSON.
|
||||
const MAX_CONCURRENT_LOADS = 8;
|
||||
let activeLoads = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
interface QueueEntry {
|
||||
start: () => void;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
function runQueued<T>(task: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const entry: QueueEntry = { start: () => {}, started: false };
|
||||
const onAbort = () => {
|
||||
// Drop the still-queued entry so an unmounted tile cannot hold a slot. When the
|
||||
// request has already started, the abort signal rejects the in-flight fetch instead.
|
||||
if (entry.started) {
|
||||
return;
|
||||
}
|
||||
const i = queue.indexOf(entry.start);
|
||||
if (i !== -1) {
|
||||
queue.splice(i, 1);
|
||||
}
|
||||
reject(abortError());
|
||||
};
|
||||
entry.start = () => {
|
||||
entry.started = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
activeLoads++;
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeLoads--;
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
});
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
reject(abortError());
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (activeLoads < MAX_CONCURRENT_LOADS) {
|
||||
entry.start();
|
||||
} else {
|
||||
queue.push(entry.start);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function requestTextPreview(url: string, signal: AbortSignal | undefined): Promise<TextPreviewPayload> {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
return runQueued(async () => {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: { sessionId: state.sessionId },
|
||||
...(signal && { signal }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Text preview request failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as TextPreviewPayload;
|
||||
}, signal);
|
||||
}
|
||||
|
||||
interface TextPreviewCacheEntry {
|
||||
promise: Promise<TextPreviewPayload>;
|
||||
controller: AbortController;
|
||||
/** Number of mounted consumers (tile and/or popup) that still need this request. */
|
||||
refs: number;
|
||||
/** True once the request has resolved or rejected and is safe to keep without a consumer. */
|
||||
settled?: boolean;
|
||||
}
|
||||
|
||||
const cache = new Map<string, TextPreviewCacheEntry>();
|
||||
|
||||
/**
|
||||
* Fetches a text preview, reusing a cached result for the same URL and modified time.
|
||||
* Failed requests are not cached, so a later view can retry.
|
||||
* Failed requests are not cached, so a later view can retry. Each call takes a
|
||||
* reference; the matching releaseTextPreview call lets a still-pending request be
|
||||
* aborted once every consumer has unmounted.
|
||||
*/
|
||||
export function fetchTextPreview(url: string, modified: string | null): Promise<TextPreviewPayload> {
|
||||
const key = `${url}|${modified ?? ""}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) {
|
||||
// Re-insert to mark as most recently used.
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached;
|
||||
const key = textPreviewCacheKey(url, modified);
|
||||
const existing = cache.get(key);
|
||||
if (existing !== undefined) {
|
||||
existing.refs++;
|
||||
return existing.promise;
|
||||
}
|
||||
const request = requestTextPreview(url);
|
||||
cache.set(key, request);
|
||||
const controller = new AbortController();
|
||||
const entry: TextPreviewCacheEntry = {
|
||||
promise: new Promise<TextPreviewPayload>(() => {}),
|
||||
controller,
|
||||
refs: 1,
|
||||
settled: false,
|
||||
};
|
||||
// Mark settled as soon as the request finishes so a later release never aborts a
|
||||
// result that is already available (the popup reopen / scroll-back case).
|
||||
entry.promise = requestTextPreview(url, controller.signal).finally(() => {
|
||||
entry.settled = true;
|
||||
});
|
||||
cache.set(key, entry);
|
||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
cache.delete(oldest);
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
cache.get(oldestKey)?.controller.abort();
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
request.catch(() => {
|
||||
if (cache.get(key) === request) {
|
||||
entry.promise.catch(() => {
|
||||
if (cache.get(key) === entry) {
|
||||
cache.delete(key);
|
||||
}
|
||||
});
|
||||
return request;
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** Test helper: clears the in-memory text preview cache. */
|
||||
export function clearTextPreviewCache(): void {
|
||||
cache.clear();
|
||||
/**
|
||||
* Drops the reference taken by fetchTextPreview. When the last consumer releases, the
|
||||
* in-flight request is aborted and removed, so queued work for unmounted tiles does not
|
||||
* delay previews for the folder the user is actually looking at. Requests still shared
|
||||
* by an active consumer are kept.
|
||||
*/
|
||||
export function releaseTextPreview(url: string, modified: string | null): void {
|
||||
const key = textPreviewCacheKey(url, modified);
|
||||
const entry = cache.get(key);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
entry.refs--;
|
||||
// Only abort a request that has not finished. A settled result stays cached so the
|
||||
// popup can reopen (and virtualization can scroll back) without re-fetching.
|
||||
if (entry.refs <= 0 && !entry.settled) {
|
||||
entry.controller.abort();
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Final sanitized/rendered HTML is the expensive part of a text preview. Virtualization
|
||||
// unmounts off-window tiles and the popup unmounts on mouse-out, so without this every
|
||||
// scroll-back and hover would re-run markdown parsing + DOMPurify.
|
||||
const MAX_RENDERED_ENTRIES = 200;
|
||||
const renderedCache = new Map<string, string>();
|
||||
|
||||
/** Cache key for a rendered text preview, matching fetchTextPreview's key. */
|
||||
export function textPreviewCacheKey(url: string, modified: string | null): string {
|
||||
return `${url}|${modified ?? ""}`;
|
||||
}
|
||||
|
||||
/** Returns the cached rendered HTML for a key, marking it as most recently used. */
|
||||
export function getRenderedTextPreview(key: string): string | undefined {
|
||||
const value = renderedCache.get(key);
|
||||
if (value !== undefined) {
|
||||
renderedCache.delete(key);
|
||||
renderedCache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Stores rendered HTML for a key, evicting the least recently used entry past the limit. */
|
||||
export function setRenderedTextPreview(key: string, html: string): void {
|
||||
renderedCache.delete(key);
|
||||
renderedCache.set(key, html);
|
||||
if (renderedCache.size > MAX_RENDERED_ENTRIES) {
|
||||
const oldest = renderedCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
renderedCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: clears the in-memory text preview caches. */
|
||||
export function clearTextPreviewCache(): void {
|
||||
for (const entry of cache.values()) {
|
||||
entry.controller.abort();
|
||||
}
|
||||
cache.clear();
|
||||
renderedCache.clear();
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
import { expect, test } from "../test-setup.ts";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test, ensureListingViewMode } from "../test-setup.ts";
|
||||
|
||||
const FILES_DIR = "/files/playwright-files/files/";
|
||||
const REMOTE_IMAGE_ALT = "Remote screenshot from filebrowserquantum.com";
|
||||
|
||||
/** Drive the StatusBar size slider so the gallery tiles are wide enough for a text preview. */
|
||||
async function setGallerySize(page: Page, size: number): Promise<void> {
|
||||
await page.locator("#gallery-size").evaluate((el, value) => {
|
||||
const input = el as HTMLInputElement;
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, size);
|
||||
}
|
||||
|
||||
/** Text previews only render inline once the tile is wide enough; gallery does that. */
|
||||
async function showTextPreviewGallery(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await setGallerySize(page, 9);
|
||||
await ensureListingViewMode(page, "gallery");
|
||||
}
|
||||
|
||||
// Thumbnails of HTML files must not show images or videos. The full viewer still shows them.
|
||||
|
||||
test("html thumbnail in the listing shows text but no images or videos", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await showTextPreviewGallery(page, FILES_DIR);
|
||||
const tile = page.locator('a[aria-label="index.html"] .text-preview');
|
||||
await tile.waitFor({ state: "visible" });
|
||||
await expect(tile).toContainText("Relative path resolution");
|
||||
await expect(tile.locator("img, image, picture, video")).toHaveCount(0);
|
||||
await expect(tile.locator("[srcset], [background]")).toHaveCount(0);
|
||||
// The truncation fade was removed; truncated content is clipped instead.
|
||||
await expect(tile.locator(".text-preview-fade")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("html thumbnail for a page with a remote image omits the image", async ({ page }) => {
|
||||
await page.goto(`${FILES_DIR}nested/`);
|
||||
await showTextPreviewGallery(page, `${FILES_DIR}nested/`);
|
||||
const tile = page.locator('a[aria-label="child.html"] .text-preview');
|
||||
await tile.waitFor({ state: "visible" });
|
||||
await expect(tile).toContainText("Child page");
|
||||
@@ -23,6 +43,29 @@ test("html thumbnail for a page with a remote image omits the image", async ({ p
|
||||
await expect(tile.locator(`img[alt="${REMOTE_IMAGE_ALT}"]`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("small tiles show a preview-available indicator instead of the text preview", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await ensureListingViewMode(page, "list");
|
||||
|
||||
const item = page.locator('a[aria-label="index.html"]');
|
||||
// The tile is too small to render text, so the icon plus a badge is shown instead.
|
||||
await expect(item.locator(".text-preview")).toHaveCount(0);
|
||||
await expect(item.locator(".overlay-icon--preview-hint")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("hovering a small tile still opens the text preview popup", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await ensureListingViewMode(page, "list");
|
||||
|
||||
const item = page.locator('a[aria-label="index.html"]');
|
||||
// The popup handler lives on the small .image-preview Icon, not the full-width list row,
|
||||
// so hover the first preview icon (the quick-download icon can add a second one).
|
||||
await item.locator(".image-preview").first().hover();
|
||||
const popup = page.locator(".popup-preview .text-preview");
|
||||
await popup.waitFor({ state: "visible" });
|
||||
await expect(popup).toContainText("Relative path resolution");
|
||||
});
|
||||
|
||||
test("full html viewer shows relative images and videos", async ({ page }) => {
|
||||
await page.goto(`${FILES_DIR}index.html`);
|
||||
const frame = page.frameLocator("iframe.html-content");
|
||||
|
||||
@@ -8,7 +8,6 @@ import stripLegacyCSS from "./scripts/postcss-strip-legacy.ts";
|
||||
import protectTemplateStyles from "./scripts/vite-protect-template-styles.ts";
|
||||
|
||||
const isDevBuild = process.env.DEV_BUILD === "true";
|
||||
const backendWebDist = path.resolve(import.meta.dirname, "../backend/internal/web/dist");
|
||||
// Docker builds skip vue-tsc here; CI type-checks in regular-tests.yaml.
|
||||
const skipTypecheck = process.env.SKIP_TYPECHECK === "1";
|
||||
|
||||
@@ -122,7 +121,6 @@ export default defineConfig(({ command }) => {
|
||||
base: "",
|
||||
define,
|
||||
build: {
|
||||
outDir: backendWebDist,
|
||||
emptyOutDir: true,
|
||||
// Optimize for watch mode stability
|
||||
watch: isDevBuild ? {
|
||||
|
||||
@@ -19,7 +19,7 @@ backend_dev_tool = $$(cd $(BACKEND_BUILD) && go tool -n $(1))
|
||||
|
||||
.SILENT:
|
||||
|
||||
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend dev run generate-docs
|
||||
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend embed-frontend dev run generate-docs
|
||||
.PHONY: lint-frontend lint-backend lint test test-backend test-frontend check-all
|
||||
.PHONY: check-translations sync-translations test-playwright test-playwright-project test-playwright-performance playwright-base playwright-perf-base perf-check perf-smoke perf-baseline perf-dashboard run-proxy screenshots
|
||||
.PHONY: check-icons generate-icons sync-icons
|
||||
@@ -52,7 +52,13 @@ build-docker-slim:
|
||||
build-docker-media:
|
||||
DOCKER_BUILDKIT=1 docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser-media -f _docker/Dockerfile.media .
|
||||
|
||||
build-backend:
|
||||
embed-frontend:
|
||||
@test -d frontend/dist || (echo "Missing frontend/dist; run 'make build-frontend' first." >&2 && exit 1)
|
||||
mkdir -p backend/internal/web/dist
|
||||
find backend/internal/web/dist -mindepth 1 ! -name '.gitkeep' ! -name 'config.generated.yaml' -delete
|
||||
cp -r frontend/dist/. backend/internal/web/dist/
|
||||
|
||||
build-backend: embed-frontend
|
||||
@echo "Building backend..."
|
||||
cd backend && go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=testing'"
|
||||
@echo "✓ Backend built successfully"
|
||||
@@ -64,6 +70,7 @@ dev: generate-docs generate-icons
|
||||
bash ./scripts/dev.sh
|
||||
|
||||
run: build-frontend generate-docs
|
||||
$(MAKE) embed-frontend
|
||||
cd backend && $(call backend_dev_tool,swag) init --output swagger/docs
|
||||
@if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' '/func init/,+3d' backend/swagger/docs/docs.go; \
|
||||
@@ -93,6 +100,7 @@ build-frontend:
|
||||
lint-frontend:
|
||||
cd frontend && npm run lint
|
||||
cd frontend && npm run lint:css
|
||||
cd frontend && npm run typecheck
|
||||
|
||||
lint-backend:
|
||||
cd backend && GOLANGCI_LINT="$$(cd $(BACKEND_BUILD) && go tool -n golangci-lint)" && "$$GOLANGCI_LINT" run --path-prefix=backend
|
||||
@@ -137,7 +145,7 @@ PLAYWRIGHT_PROJECTS := sharing settings noauth general jwt proxy previews oidc n
|
||||
# make test-playwright-project PROJECT=previews
|
||||
# or:
|
||||
# make test-playwright PROJECT=previews
|
||||
test-playwright: build-frontend
|
||||
test-playwright: build-frontend embed-frontend
|
||||
cd backend && GOOS=linux go build -o filebrowser .
|
||||
ifeq ($(PROJECT),)
|
||||
$(foreach p,$(PLAYWRIGHT_PROJECTS),docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-$(p) . ;)
|
||||
@@ -165,7 +173,7 @@ playwright-perf-base:
|
||||
--build-arg PLAYWRIGHT_BROWSERS="$(PERF_PLAYWRIGHT_BROWSERS)" \
|
||||
-f _docker/Dockerfile.playwright-perf-base .
|
||||
|
||||
test-playwright-performance: build-frontend
|
||||
test-playwright-performance: build-frontend embed-frontend
|
||||
cd backend && GOOS=linux go build -o filebrowser .
|
||||
DOCKER_BUILDKIT=1 docker build --target ci -t filebrowser-playwright-performance \
|
||||
-f _docker/Dockerfile.playwright-performance .
|
||||
@@ -181,7 +189,9 @@ PERF_BROWSERS ?= chromium,firefox,webkit
|
||||
# Only chromium feeds the CI baseline; the others are advisory insight.
|
||||
perf-check:
|
||||
ifndef PERF_SKIP_BUILD
|
||||
$(MAKE) build-frontend
|
||||
$(MAKE) build-frontend embed-frontend
|
||||
else
|
||||
$(MAKE) embed-frontend
|
||||
endif
|
||||
cd backend && GOOS=linux go build -o filebrowser .
|
||||
ifndef PERF_SKIP_PLAYWRIGHT_BASE
|
||||
@@ -212,7 +222,7 @@ perf-dashboard:
|
||||
# get version from environment variable, for example
|
||||
# cd frontend && npm i @playwright/test && npx playwright install --with-deps chromium
|
||||
# make PLAYWRIGHT_TEST=settings test-playwright-ui
|
||||
test-playwright-ui: build-frontend
|
||||
test-playwright-ui: build-frontend embed-frontend
|
||||
docker stop local-playwright-tests || true
|
||||
docker rm local-playwright-tests || true
|
||||
rm -rf _docker/src/tmp/ || true && mkdir -p _docker/src/tmp/
|
||||
@@ -233,7 +243,7 @@ run-jwt: build-frontend
|
||||
# optional: install playwright locally
|
||||
# once local playwright server is running, you can also watch the tests interactively with:
|
||||
# cd frontend && npx playwright test --project dark-screenshots --ui
|
||||
screenshots: build-frontend
|
||||
screenshots: build-frontend embed-frontend
|
||||
cd backend && GOOS=linux go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=latest'" .
|
||||
@echo "Running screenshots..."
|
||||
cd _docker && docker compose down && docker compose up --build local-playwright-screenshots
|
||||
|
||||
Reference in new issue
Block a user