diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go
index 52e2c2c..fea9727 100644
--- a/Overlord-Client/cmd/agent/handlers/command.go
+++ b/Overlord-Client/cmd/agent/handlers/command.go
@@ -2056,6 +2056,35 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
payload, _ := envelope["payload"].(map[string]interface{})
path, _ := payload["path"].(string)
return HandleFileExecute(ctx, env, cmdID, path)
+ case "file_icon":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ items := parseIconRequestItems(payload["items"])
+ goSafe("file_icon", env.Cancel, func() {
+ if err := HandleFileIcon(ctx, env, cmdID, items); err != nil && err != context.Canceled {
+ log.Printf("file_icon error: %v", err)
+ }
+ })
+ return nil
+ case "file_thumb":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ items := parseThumbRequestItems(payload["items"])
+ goSafe("file_thumb", env.Cancel, func() {
+ if err := HandleFileThumbnail(ctx, env, cmdID, items); err != nil && err != context.Canceled {
+ log.Printf("file_thumb error: %v", err)
+ }
+ })
+ return nil
+ case "file_dirsize":
+ path, _ := envelopePayloadString(envelope, "path")
+ dirsizeCtx, cancel := context.WithCancel(ctx)
+ registerCancellableCommand(cmdID, cancel)
+ goSafe("file_dirsize", env.Cancel, func() {
+ defer unregisterCommand(cmdID)
+ if err := HandleFolderSize(dirsizeCtx, env, cmdID, path); err != nil && err != context.Canceled {
+ log.Printf("file_dirsize error: %v", err)
+ }
+ })
+ return nil
case "agent_update":
payload, _ := envelope["payload"].(map[string]interface{})
path, _ := payload["path"].(string)
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras.go b/Overlord-Client/cmd/agent/handlers/files_extras.go
new file mode 100644
index 0000000..860f593
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_extras.go
@@ -0,0 +1,174 @@
+package handlers
+
+import (
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+
+ agentRuntime "overlord-client/cmd/agent/runtime"
+ "overlord-client/cmd/agent/wire"
+)
+
+func parseIconRequestItems(raw interface{}) []wire.FileIconRequestItem {
+ arr, _ := raw.([]interface{})
+ out := make([]wire.FileIconRequestItem, 0, len(arr))
+ for _, v := range arr {
+ m := coerceStringMap(v)
+ if m == nil {
+ continue
+ }
+ key, _ := m["key"].(string)
+ if key == "" {
+ continue
+ }
+ path, _ := m["path"].(string)
+ ext, _ := m["ext"].(string)
+ out = append(out, wire.FileIconRequestItem{Key: key, Path: path, Ext: ext})
+ }
+ return out
+}
+
+func parseThumbRequestItems(raw interface{}) []wire.FileThumbnailRequestItem {
+ arr, _ := raw.([]interface{})
+ out := make([]wire.FileThumbnailRequestItem, 0, len(arr))
+ for _, v := range arr {
+ m := coerceStringMap(v)
+ if m == nil {
+ continue
+ }
+ key, _ := m["key"].(string)
+ path, _ := m["path"].(string)
+ if key == "" || path == "" {
+ continue
+ }
+ size := 0
+ switch s := m["size"].(type) {
+ case float64:
+ size = int(s)
+ case int64:
+ size = int(s)
+ case uint64:
+ size = int(s)
+ case int:
+ size = s
+ }
+ out = append(out, wire.FileThumbnailRequestItem{Key: key, Path: path, Size: size})
+ }
+ return out
+}
+
+func coerceStringMap(v interface{}) map[string]interface{} {
+ if m, ok := v.(map[string]interface{}); ok {
+ return m
+ }
+ if m, ok := v.(map[interface{}]interface{}); ok {
+ out := make(map[string]interface{}, len(m))
+ for k, val := range m {
+ if ks, ok := k.(string); ok {
+ out[ks] = val
+ }
+ }
+ return out
+ }
+ return nil
+}
+
+func HandleFileIcon(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileIconRequestItem) error {
+ out := make([]wire.FileIconResultItem, 0, len(items))
+ for _, item := range items {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ png, err := extractFileIconPNG(item.Path, item.Ext)
+ ri := wire.FileIconResultItem{Key: item.Key}
+ if err != nil {
+ ri.Error = err.Error()
+ } else {
+ ri.PNG = png
+ }
+ out = append(out, ri)
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.FileIconResult{
+ Type: "file_icon_result",
+ CommandID: cmdID,
+ Icons: out,
+ })
+}
+
+func HandleFileThumbnail(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileThumbnailRequestItem) error {
+ out := make([]wire.FileThumbnailResultItem, 0, len(items))
+ for _, item := range items {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ jpegBytes, w, h, err := extractFileThumbnailJPEG(item.Path, item.Size)
+ ri := wire.FileThumbnailResultItem{Key: item.Key}
+ if err != nil {
+ ri.Error = err.Error()
+ } else {
+ ri.JPEG = jpegBytes
+ ri.W = w
+ ri.H = h
+ }
+ out = append(out, ri)
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.FileThumbnailResult{
+ Type: "file_thumb_result",
+ CommandID: cmdID,
+ Thumbs: out,
+ })
+}
+
+func HandleFolderSize(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
+ log.Printf("file_dirsize: %s", path)
+
+ var bytes, files, dirs int64
+ lastEmit := time.Now()
+
+ emit := func(done bool, errMsg string) error {
+ return wire.WriteMsg(ctx, env.Conn, wire.FolderSizeResult{
+ Type: "file_dirsize_result",
+ CommandID: cmdID,
+ Path: path,
+ Bytes: bytes,
+ Files: files,
+ Dirs: dirs,
+ Done: done,
+ Error: errMsg,
+ })
+ }
+
+ walkErr := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // skip unreadable entries
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ if info.IsDir() {
+ dirs++
+ } else {
+ files++
+ bytes += info.Size()
+ }
+ if time.Since(lastEmit) >= 400*time.Millisecond {
+ lastEmit = time.Now()
+ _ = emit(false, "")
+ }
+ return nil
+ })
+
+ errMsg := ""
+ if walkErr != nil && walkErr != context.Canceled {
+ errMsg = walkErr.Error()
+ }
+ return emit(true, errMsg)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_other.go b/Overlord-Client/cmd/agent/handlers/files_extras_other.go
new file mode 100644
index 0000000..3148dd9
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_extras_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package handlers
+
+import "fmt"
+
+func extractFileIconPNG(_, _ string) ([]byte, error) {
+ return nil, fmt.Errorf("file icons only supported on Windows")
+}
+
+func extractFileThumbnailJPEG(_ string, _ int) ([]byte, int, int, error) {
+ return nil, 0, 0, fmt.Errorf("file thumbnails only supported on Windows")
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows.go
new file mode 100644
index 0000000..4e460ab
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows.go
@@ -0,0 +1,269 @@
+//go:build windows
+// +build windows
+
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "image/png"
+ "log"
+ "runtime"
+ "strings"
+ "sync"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ feShell32 = windows.NewLazySystemDLL("shell32.dll")
+ feOle32 = windows.NewLazySystemDLL("ole32.dll")
+ pSHGetFileInfoW = feShell32.NewProc("SHGetFileInfoW")
+ pSHCreateItemFromParsingName = feShell32.NewProc("SHCreateItemFromParsingName")
+ pCoInitializeEx = feOle32.NewProc("CoInitializeEx")
+ pCoUninitialize = feOle32.NewProc("CoUninitialize")
+)
+
+const (
+ shgfiIcon = 0x000000100
+ shgfiSmallIcon = 0x000000001
+ shgfiLargeIcon = 0x000000000
+ shgfiUseFileAttributes = 0x000000010
+ fileAttributeNormal = 0x00000080
+ coinitApartmentThreaded = 0x2
+ coinitDisableOLE1DDE = 0x4
+
+ siigbfResizeToFit = 0x00
+ siigbfBiggerSizeOk = 0x01
+ siigbfMemoryOnly = 0x02
+ siigbfIconOnly = 0x04
+ siigbfThumbnailOnly = 0x08
+ siigbfInCacheOnly = 0x10
+)
+
+type shFileInfoW struct {
+ HIcon uintptr
+ IIcon int32
+ DwAttributes uint32
+ SzDisplayName [260]uint16
+ SzTypeName [80]uint16
+}
+
+var iidIShellItemImageFactory = windows.GUID{
+ Data1: 0xbcc18b79, Data2: 0xba16, Data3: 0x442f,
+ Data4: [8]byte{0x80, 0xc4, 0x8a, 0x59, 0xc3, 0x0c, 0x46, 0x3b},
+}
+
+type iShellItemImageFactoryVtbl struct {
+ QueryInterface uintptr
+ AddRef uintptr
+ Release uintptr
+ GetImage uintptr
+}
+
+type iShellItemImageFactory struct {
+ Vtbl *iShellItemImageFactoryVtbl
+}
+
+type comJob struct {
+ fn func()
+ done chan struct{}
+}
+
+var (
+ comStartOnce sync.Once
+ comQueue chan comJob
+)
+
+func runOnCOMThread(fn func()) {
+ comStartOnce.Do(func() {
+ comQueue = make(chan comJob, 256)
+ go func() {
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ pCoInitializeEx.Call(0, uintptr(coinitApartmentThreaded|coinitDisableOLE1DDE))
+ defer pCoUninitialize.Call()
+ for job := range comQueue {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("com worker: recovered panic: %v", r)
+ }
+ }()
+ job.fn()
+ }()
+ close(job.done)
+ }
+ }()
+ })
+ done := make(chan struct{})
+ comQueue <- comJob{fn: fn, done: done}
+ <-done
+}
+
+func hBitmapToRGBA(hBitmap uintptr) *image.RGBA {
+ var bm bitmapObj
+ pGetObjectW.Call(hBitmap, unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm)))
+ if bm.BmWidth <= 0 || bm.BmHeight <= 0 {
+ return nil
+ }
+ w := int(bm.BmWidth)
+ h := int(bm.BmHeight)
+
+ hdc, _, _ := pCreateCompatibleDC.Call(0)
+ if hdc == 0 {
+ return nil
+ }
+ defer pDeleteDC.Call(hdc)
+
+ bih := bitmapInfoHeader{
+ BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
+ BiWidth: int32(w),
+ BiHeight: -int32(h),
+ BiPlanes: 1,
+ BiBitCount: 32,
+ }
+ pixels := make([]byte, w*h*4)
+ pGetDIBits.Call(hdc, hBitmap, 0, uintptr(h),
+ uintptr(unsafe.Pointer(&pixels[0])),
+ uintptr(unsafe.Pointer(&bih)),
+ 0,
+ )
+
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ off := (y*w + x) * 4
+ img.SetRGBA(x, y, color.RGBA{
+ R: pixels[off+2],
+ G: pixels[off+1],
+ B: pixels[off+0],
+ A: pixels[off+3],
+ })
+ }
+ }
+ return img
+}
+
+func extractFileIconPNG(targetPath, ext string) ([]byte, error) {
+ var queryPath string
+ flags := uint32(shgfiIcon | shgfiLargeIcon)
+ if targetPath != "" {
+ queryPath = targetPath
+ } else if ext != "" {
+ queryPath = "x." + strings.TrimPrefix(strings.ToLower(ext), ".")
+ flags |= shgfiUseFileAttributes
+ } else {
+ return nil, fmt.Errorf("path or ext required")
+ }
+
+ pathPtr, err := windows.UTF16PtrFromString(queryPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var out []byte
+ var outErr error
+ runOnCOMThread(func() {
+ var sfi shFileInfoW
+ ret, _, _ := pSHGetFileInfoW.Call(
+ uintptr(unsafe.Pointer(pathPtr)),
+ uintptr(fileAttributeNormal),
+ uintptr(unsafe.Pointer(&sfi)),
+ unsafe.Sizeof(sfi),
+ uintptr(flags),
+ )
+ if ret == 0 || sfi.HIcon == 0 {
+ outErr = fmt.Errorf("SHGetFileInfo failed")
+ return
+ }
+ defer pDestroyIcon.Call(sfi.HIcon)
+
+ var ii iconInfo
+ r, _, _ := pGetIconInfo.Call(sfi.HIcon, uintptr(unsafe.Pointer(&ii)))
+ if r == 0 {
+ outErr = fmt.Errorf("GetIconInfo failed")
+ return
+ }
+ if ii.HbmMask != 0 {
+ defer pDeleteObject.Call(uintptr(ii.HbmMask))
+ }
+ if ii.HbmColor == 0 {
+ outErr = fmt.Errorf("no color bitmap")
+ return
+ }
+ defer pDeleteObject.Call(uintptr(ii.HbmColor))
+
+ img := hBitmapToRGBA(uintptr(ii.HbmColor))
+ if img == nil {
+ outErr = fmt.Errorf("bitmap conversion failed")
+ return
+ }
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ outErr = err
+ return
+ }
+ out = buf.Bytes()
+ })
+ return out, outErr
+}
+
+func extractFileThumbnailJPEG(path string, edge int) ([]byte, int, int, error) {
+ if edge <= 0 {
+ edge = 128
+ }
+ pathPtr, err := windows.UTF16PtrFromString(path)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ var out []byte
+ var outW, outH int
+ var outErr error
+ runOnCOMThread(func() {
+ var itemPtr uintptr
+ hr, _, _ := pSHCreateItemFromParsingName.Call(
+ uintptr(unsafe.Pointer(pathPtr)),
+ 0,
+ uintptr(unsafe.Pointer(&iidIShellItemImageFactory)),
+ uintptr(unsafe.Pointer(&itemPtr)),
+ )
+ if hr != 0 || itemPtr == 0 {
+ outErr = fmt.Errorf("SHCreateItemFromParsingName failed: 0x%x", hr)
+ return
+ }
+ factory := (*iShellItemImageFactory)(unsafe.Pointer(itemPtr))
+ defer callRelease(factory.Vtbl.Release, itemPtr)
+
+ flags := uint32(siigbfThumbnailOnly | siigbfBiggerSizeOk)
+ var hBitmap uintptr
+ hr2 := callGetImage(factory.Vtbl.GetImage, itemPtr, int32(edge), int32(edge), flags, &hBitmap)
+ if hr2 != 0 || hBitmap == 0 {
+ outErr = fmt.Errorf("GetImage failed: 0x%x", hr2)
+ return
+ }
+ defer pDeleteObject.Call(hBitmap)
+
+ img := hBitmapToRGBA(hBitmap)
+ if img == nil {
+ outErr = fmt.Errorf("bitmap conversion failed")
+ return
+ }
+
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
+ outErr = err
+ return
+ }
+ out = buf.Bytes()
+ outW = img.Bounds().Dx()
+ outH = img.Bounds().Dy()
+ })
+ return out, outW, outH, outErr
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go
new file mode 100644
index 0000000..c0ae86d
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows_386.go
@@ -0,0 +1,24 @@
+//go:build windows && 386
+
+package handlers
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr {
+ hr, _, _ := syscall.SyscallN(
+ getImageFn,
+ this,
+ uintptr(uint32(cx)),
+ uintptr(uint32(cy)),
+ uintptr(flags),
+ uintptr(unsafe.Pointer(hBitmapOut)),
+ )
+ return hr
+}
+
+func callRelease(releaseFn, this uintptr) {
+ syscall.SyscallN(releaseFn, this)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go
new file mode 100644
index 0000000..1e4f278
--- /dev/null
+++ b/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go
@@ -0,0 +1,24 @@
+//go:build windows && (amd64 || arm64)
+
+package handlers
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr {
+ sizeWord := uintptr(uint64(uint32(cx)) | (uint64(uint32(cy)) << 32))
+ hr, _, _ := syscall.SyscallN(
+ getImageFn,
+ this,
+ sizeWord,
+ uintptr(flags),
+ uintptr(unsafe.Pointer(hBitmapOut)),
+ )
+ return hr
+}
+
+func callRelease(releaseFn, this uintptr) {
+ syscall.SyscallN(releaseFn, this)
+}
diff --git a/Overlord-Client/cmd/agent/handlers/files_windows.go b/Overlord-Client/cmd/agent/handlers/files_windows.go
index b774faf..9161eef 100644
--- a/Overlord-Client/cmd/agent/handlers/files_windows.go
+++ b/Overlord-Client/cmd/agent/handlers/files_windows.go
@@ -7,12 +7,15 @@ import (
"fmt"
"os"
"os/exec"
+ "syscall"
"overlord-client/cmd/agent/wire"
)
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
-
+ if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
+ entry.Attrs = d.FileAttributes
+ }
}
func ChangeFilePermissions(path string, mode string) error {
diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go
index 8db01cf..e56bcb9 100644
--- a/Overlord-Client/cmd/agent/wire/protocol.go
+++ b/Overlord-Client/cmd/agent/wire/protocol.go
@@ -110,6 +110,8 @@ type FileEntry struct {
Mode string `msgpack:"mode,omitempty"`
Owner string `msgpack:"owner,omitempty"`
Group string `msgpack:"group,omitempty"`
+ // windows file attributes
+ Attrs uint32 `msgpack:"attrs,omitempty"`
}
type FileListResult struct {
@@ -187,6 +189,55 @@ type FileSearchResult struct {
Error string `msgpack:"error,omitempty"`
}
+type FileIconRequestItem struct {
+ Key string `msgpack:"key"`
+ Path string `msgpack:"path,omitempty"`
+ Ext string `msgpack:"ext,omitempty"`
+}
+
+type FileIconResultItem struct {
+ Key string `msgpack:"key"`
+ PNG []byte `msgpack:"png,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileIconResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Icons []FileIconResultItem `msgpack:"icons"`
+}
+
+type FileThumbnailRequestItem struct {
+ Key string `msgpack:"key"`
+ Path string `msgpack:"path"`
+ Size int `msgpack:"size,omitempty"`
+}
+
+type FileThumbnailResultItem struct {
+ Key string `msgpack:"key"`
+ JPEG []byte `msgpack:"jpeg,omitempty"`
+ W int `msgpack:"w,omitempty"`
+ H int `msgpack:"h,omitempty"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileThumbnailResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Thumbs []FileThumbnailResultItem `msgpack:"thumbs"`
+}
+
+type FolderSizeResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Bytes int64 `msgpack:"bytes"`
+ Files int64 `msgpack:"files"`
+ Dirs int64 `msgpack:"dirs"`
+ Done bool `msgpack:"done"`
+ Error string `msgpack:"error,omitempty"`
+}
+
type ScriptResult struct {
Type string `msgpack:"type"`
CommandID string `msgpack:"commandId,omitempty"`
diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js
index 875253a..7e9d019 100644
--- a/Overlord-Server/public/assets/filebrowser.js
+++ b/Overlord-Server/public/assets/filebrowser.js
@@ -266,6 +266,15 @@ function handleMessage(msg) {
case "file_search_result":
handleFileSearchResult(msg);
break;
+ case "file_icon_result":
+ handleFileIconResult(msg);
+ break;
+ case "file_thumb_result":
+ handleFileThumbResult(msg);
+ break;
+ case "file_dirsize_result":
+ handleFileDirsizeResult(msg);
+ break;
case "command_result":
console.log("[DEBUG] Command result:", msg);
handleCommandResult(msg);
@@ -596,16 +605,389 @@ const FILE_ICON_MAP = {
vhd: "fa-compact-disc text-slate-300", vmdk: "fa-compact-disc text-slate-300",
};
+// Extensions whose icon depends on the actual file (embedded resources),
+// not just the registered shell association.
+const SELF_ICONNING_EXTS = new Set(["exe", "ico", "lnk", "dll", "msi", "cpl", "scr"]);
+// Extensions we'll request a Windows shell thumbnail for. Skip everything else.
+const THUMBNAIL_EXTS = new Set([
+ "jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif",
+ "mp4", "mkv", "mov", "avi", "webm", "m4v",
+ "pdf",
+ "docx", "xlsx", "pptx",
+]);
+
+// key → { blobUrl?: string, failed?: boolean, pending?: boolean }
+const iconCache = new Map();
+const thumbCache = new Map();
+
+// Queues + batching
+const iconQueue = []; // {key, path?, ext?}
+const thumbQueue = []; // {key, path, size}
+const iconCommandsInFlight = new Set();
+const thumbCommandsInFlight = new Set();
+let iconFlushScheduled = false;
+let thumbFlushScheduled = false;
+const ICON_BATCH_SIZE = 32;
+const ICON_BATCH_DELAY_MS = 60;
+const THUMB_BATCH_SIZE = 8;
+const THUMB_BATCH_DELAY_MS = 80;
+const THUMB_EDGE = 96;
+const MAX_THUMB_INFLIGHT_COMMANDS = 2;
+
+function iconCacheKey(entry) {
+ if (entry.isDir) return null;
+ const ext = getFileExt(entry.name);
+ if (!ext) return `ext:_noext`;
+ if (SELF_ICONNING_EXTS.has(ext)) {
+ return `path:${entry.path}|${entry.size}|${entry.modTime}`;
+ }
+ return `ext:${ext}`;
+}
+
+function thumbCacheKey(entry) {
+ if (entry.isDir) return null;
+ const ext = getFileExt(entry.name);
+ if (!THUMBNAIL_EXTS.has(ext)) return null;
+ // Skip absurdly large files — Windows providers may bog down. 256MB cap.
+ if (entry.size > 256 * 1024 * 1024) return null;
+ return `thumb:${entry.path}|${entry.size}|${entry.modTime}|${THUMB_EDGE}`;
+}
+
+function scheduleIconFlush() {
+ if (iconFlushScheduled) return;
+ iconFlushScheduled = true;
+ setTimeout(flushIconQueue, ICON_BATCH_DELAY_MS);
+}
+
+function flushIconQueue() {
+ iconFlushScheduled = false;
+ if (iconQueue.length === 0) return;
+ const batch = iconQueue.splice(0, ICON_BATCH_SIZE);
+ const commandId = `icon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ iconCommandsInFlight.add(commandId);
+ send({
+ type: "command",
+ commandType: "file_icon",
+ id: commandId,
+ payload: { items: batch },
+ });
+ if (iconQueue.length > 0) scheduleIconFlush();
+}
+
+function scheduleThumbFlush() {
+ if (thumbFlushScheduled) return;
+ if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return;
+ thumbFlushScheduled = true;
+ setTimeout(flushThumbQueue, THUMB_BATCH_DELAY_MS);
+}
+
+function flushThumbQueue() {
+ thumbFlushScheduled = false;
+ if (thumbQueue.length === 0) return;
+ if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return;
+ const batch = thumbQueue.splice(0, THUMB_BATCH_SIZE);
+ const commandId = `thumb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ thumbCommandsInFlight.add(commandId);
+ send({
+ type: "command",
+ commandType: "file_thumb",
+ id: commandId,
+ payload: { items: batch },
+ });
+ if (thumbQueue.length > 0) scheduleThumbFlush();
+}
+
+function requestIconFor(entry) {
+ const key = iconCacheKey(entry);
+ if (!key) return null;
+ if (iconCache.has(key)) return key;
+ iconCache.set(key, { pending: true });
+ const item = { key };
+ if (key.startsWith("path:")) {
+ item.path = entry.path;
+ } else {
+ const ext = getFileExt(entry.name);
+ if (ext) item.ext = ext;
+ else item.ext = "";
+ }
+ iconQueue.push(item);
+ scheduleIconFlush();
+ return key;
+}
+
+function requestThumbFor(entry) {
+ const key = thumbCacheKey(entry);
+ if (!key) return null;
+ if (thumbCache.has(key)) return key;
+ thumbCache.set(key, { pending: true });
+ thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE });
+ scheduleThumbFlush();
+ return key;
+}
+
+function handleFileIconResult(msg) {
+ if (msg.commandId) iconCommandsInFlight.delete(msg.commandId);
+ const items = Array.isArray(msg.icons) ? msg.icons : [];
+ for (const item of items) {
+ if (!item || !item.key) continue;
+ const entry = iconCache.get(item.key) || {};
+ entry.pending = false;
+ if (item.png && item.png.length > 0) {
+ const blob = new Blob([item.png], { type: "image/png" });
+ entry.blobUrl = URL.createObjectURL(blob);
+ } else {
+ entry.failed = true;
+ }
+ iconCache.set(item.key, entry);
+ applyIconToDom(item.key, entry);
+ }
+}
+
+function handleFileThumbResult(msg) {
+ if (msg.commandId) thumbCommandsInFlight.delete(msg.commandId);
+ // Pump the next thumbnail batch now that there's capacity.
+ if (thumbQueue.length > 0) scheduleThumbFlush();
+ const items = Array.isArray(msg.thumbs) ? msg.thumbs : [];
+ for (const item of items) {
+ if (!item || !item.key) continue;
+ const entry = thumbCache.get(item.key) || {};
+ entry.pending = false;
+ if (item.jpeg && item.jpeg.length > 0) {
+ const blob = new Blob([item.jpeg], { type: "image/jpeg" });
+ entry.blobUrl = URL.createObjectURL(blob);
+ entry.w = item.w || 0;
+ entry.h = item.h || 0;
+ } else {
+ entry.failed = true;
+ }
+ thumbCache.set(item.key, entry);
+ applyThumbToDom(item.key, entry);
+ }
+}
+
+function applyIconToDom(key, entry) {
+ if (!entry || !entry.blobUrl) return;
+ const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"');
+ document.querySelectorAll(`[data-icon-key="${escaped}"]`).forEach((el) => {
+ el.innerHTML = ``;
+ });
+}
+
+function applyThumbToDom(key, entry) {
+ if (!entry || !entry.blobUrl) return;
+ const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"');
+ // Thumbs are used by hover-preview popover; just update the popover if it's currently showing this key.
+ if (currentPopoverKey === key) {
+ renderQuickLookPopover(currentPopoverEntry, entry.blobUrl, entry.w, entry.h);
+ }
+}
+
+// Windows FILE_ATTRIBUTE_* flags surfaced as compact badges next to the filename.
+const ATTR_READONLY = 0x00000001;
+const ATTR_HIDDEN = 0x00000002;
+const ATTR_SYSTEM = 0x00000004;
+const ATTR_REPARSE_POINT = 0x00000400;
+const ATTR_COMPRESSED = 0x00000800;
+const ATTR_ENCRYPTED = 0x00004000;
+
+function renderAttrBadges(attrs) {
+ if (!attrs || typeof attrs !== "number") return "";
+ const pills = [];
+ const pill = (label, title, cls) =>
+ `${label}`;
+ if (attrs & ATTR_HIDDEN) pills.push(pill("H", "Hidden", "bg-slate-700 text-slate-300"));
+ if (attrs & ATTR_SYSTEM) pills.push(pill("S", "System", "bg-red-900/60 text-red-200"));
+ if (attrs & ATTR_REPARSE_POINT) pills.push(pill("L", "Reparse point / symlink", "bg-indigo-900/60 text-indigo-200"));
+ if (attrs & ATTR_COMPRESSED) pills.push(pill("C", "NTFS compressed", "bg-blue-900/60 text-blue-200"));
+ if (attrs & ATTR_ENCRYPTED) pills.push(pill("E", "NTFS encrypted (EFS)", "bg-emerald-900/60 text-emerald-200"));
+ if (attrs & ATTR_READONLY) pills.push(pill("R", "Read-only", "bg-slate-700 text-slate-300"));
+ if (pills.length === 0) return "";
+ return `${pills.join("")}`;
+}
+
+// ---- Hover-preview / spacebar quicklook popover --------------------------------
+let currentPopoverKey = null;
+let currentPopoverEntry = null;
+let hoverPreviewTimer = null;
+let popoverEl = null;
+let lastHoveredRow = null;
+let lastHoveredEntry = null;
+const HOVER_DELAY_MS = 350;
+
+function ensurePopover() {
+ if (popoverEl) return popoverEl;
+ popoverEl = document.createElement("div");
+ popoverEl.id = "file-quicklook-popover";
+ popoverEl.style.cssText = "position:fixed;z-index:1500;display:none;pointer-events:none;background:#0f172a;border:1px solid #334155;border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,.5);padding:0.5rem;max-width:280px;";
+ document.body.appendChild(popoverEl);
+ return popoverEl;
+}
+
+function hideQuickLookPopover() {
+ if (hoverPreviewTimer) {
+ clearTimeout(hoverPreviewTimer);
+ hoverPreviewTimer = null;
+ }
+ currentPopoverKey = null;
+ currentPopoverEntry = null;
+ if (popoverEl) popoverEl.style.display = "none";
+}
+
+function renderQuickLookPopover(entry, thumbUrl, w, h) {
+ if (!entry) return;
+ const el = ensurePopover();
+ const ext = getFileExt(entry.name);
+ const sizeStr = entry.isDir ? "" : formatBytes(entry.size);
+ const modStr = new Date(entry.modTime * 1000).toLocaleString();
+ let imgHtml = "";
+ if (thumbUrl) {
+ imgHtml = `
`;
+ } else {
+ const iconKey = iconCacheKey(entry);
+ const iconEntry = iconKey ? iconCache.get(iconKey) : null;
+ if (iconEntry && iconEntry.blobUrl) {
+ imgHtml = `
`;
+ } else {
+ imgHtml = `