diff --git a/crates/agent-gateway/web/package.json b/crates/agent-gateway/web/package.json index 4f6f9411..5c1372ef 100644 --- a/crates/agent-gateway/web/package.json +++ b/crates/agent-gateway/web/package.json @@ -25,6 +25,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "docx-preview": "^0.3.6", "katex": "^0.16.45", "monaco-editor": "^0.55.1", "react": "^19.2.4", @@ -33,6 +34,7 @@ "remark-breaks": "^4.0.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", + "xlsx": "^0.18.5", "yet-another-react-lightbox": "^3.31.0" }, "devDependencies": { diff --git a/crates/agent-gateway/web/pnpm-lock.yaml b/crates/agent-gateway/web/pnpm-lock.yaml index cdb71bed..217a0091 100644 --- a/crates/agent-gateway/web/pnpm-lock.yaml +++ b/crates/agent-gateway/web/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + docx-preview: + specifier: ^0.3.6 + version: 0.3.7 katex: specifier: ^0.16.45 version: 0.16.45 @@ -80,6 +83,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 yet-another-react-lightbox: specifier: ^3.31.0 version: 3.31.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1124,6 +1130,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1158,6 +1168,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1186,6 +1200,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1206,6 +1224,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -1221,6 +1242,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1421,6 +1447,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + docx-preview@0.3.7: + resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} @@ -1477,6 +1506,10 @@ packages: picomatch: optional: true + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1556,10 +1589,16 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1589,6 +1628,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1613,6 +1655,9 @@ packages: engines: {node: '>=6'} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.45: resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} hasBin: true @@ -1630,6 +1675,9 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1949,6 +1997,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2000,6 +2051,9 @@ packages: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -2055,6 +2109,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2135,6 +2192,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2145,6 +2205,9 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -2158,12 +2221,19 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + streamdown@2.5.0: resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2379,6 +2449,19 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3409,6 +3492,8 @@ snapshots: acorn@8.16.0: {} + adler-32@1.3.1: {} + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -3435,6 +3520,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3462,6 +3552,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + comma-separated-tokens@2.0.3: {} commander@7.2.0: {} @@ -3474,6 +3566,8 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -3491,6 +3585,8 @@ snapshots: optionalDependencies: typescript: 6.0.2 + crc-32@1.2.2: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -3705,6 +3801,10 @@ snapshots: diff@8.0.4: {} + docx-preview@0.3.7: + dependencies: + jszip: 3.10.1 + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3749,6 +3849,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + frac@1.1.2: {} + fsevents@2.3.3: optional: true @@ -3898,11 +4000,15 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -3924,6 +4030,8 @@ snapshots: is-plain-obj@4.1.0: {} + isarray@1.0.0: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -3938,6 +4046,13 @@ snapshots: json5@2.2.3: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + katex@0.16.45: dependencies: commander: 8.3.0 @@ -3957,6 +4072,10 @@ snapshots: layout-base@2.0.1: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -4506,6 +4625,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -4571,6 +4692,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + process-nextick-args@2.0.1: {} + property-information@7.1.0: {} quansync@0.2.11: {} @@ -4620,6 +4743,16 @@ snapshots: react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -4760,12 +4893,16 @@ snapshots: rw@1.3.3: {} + safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} scheduler@0.27.0: {} semver@6.3.1: {} + setimmediate@1.0.5: {} + shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -4786,6 +4923,10 @@ snapshots: space-separated-tokens@2.0.2: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + streamdown@2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 @@ -4809,6 +4950,10 @@ snapshots: transitivePeerDependencies: - supports-color + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -4987,6 +5132,20 @@ snapshots: webpack-virtual-modules@0.6.2: {} + wmf@1.0.2: {} + + word@0.3.0: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} yet-another-react-lightbox@3.31.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index f578a103..79fd2b71 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -5004,9 +5004,9 @@ export default function GatewayApp() { workspaceEditorCleanupPending, workspaceEditorOpenRequest, workspaceEditorCloseRequestId, - workspaceImagePreviewMounted, - workspaceImagePreviewOpen, - workspaceImagePreviewOpenRequest, + workspaceFilePreviewMounted, + workspaceFilePreviewOpen, + workspaceFilePreviewOpenRequest, workspaceSshTerminalMounted, workspaceSshTerminalOpen, workspaceSshTerminalOpenRequest, @@ -5015,10 +5015,12 @@ export default function GatewayApp() { terminalSessionsVersionRef, terminalStatusSessionIdRef, projectTerminalSessions, + openWorkspaceEditorFile, + openWorkspaceFilePreview, handleWorkspaceEditorHide, handleWorkspaceEditorClosed, - requestWorkspaceImagePreviewClose, - handleWorkspaceImagePreviewClosed, + requestWorkspaceFilePreviewClose, + handleWorkspaceFilePreviewClosed, handleOpenWorkspaceFile, handleOpenSshTerminal, handleProjectTerminalSessionsChange, @@ -5880,13 +5882,15 @@ export default function GatewayApp() { workspaceEditorCloseRequestId={workspaceEditorCloseRequestId} workspaceEditorOpen={workspaceEditorOpen} workspaceEditorCleanupPending={workspaceEditorCleanupPending} + onWorkspaceEditorPreviewFile={openWorkspaceFilePreview} onWorkspaceEditorHide={handleWorkspaceEditorHide} onWorkspaceEditorClose={handleWorkspaceEditorClosed} - workspaceImagePreviewMounted={workspaceImagePreviewMounted} - workspaceImagePreviewOpenRequest={workspaceImagePreviewOpenRequest} - workspaceImagePreviewOpen={workspaceImagePreviewOpen} - onWorkspaceImagePreviewRequestClose={requestWorkspaceImagePreviewClose} - onWorkspaceImagePreviewClose={handleWorkspaceImagePreviewClosed} + workspaceFilePreviewMounted={workspaceFilePreviewMounted} + workspaceFilePreviewOpenRequest={workspaceFilePreviewOpenRequest} + workspaceFilePreviewOpen={workspaceFilePreviewOpen} + onWorkspaceFilePreviewOpenEditor={openWorkspaceEditorFile} + onWorkspaceFilePreviewRequestClose={requestWorkspaceFilePreviewClose} + onWorkspaceFilePreviewClose={handleWorkspaceFilePreviewClosed} workspaceSshTerminalMounted={workspaceSshTerminalMounted} workspaceSshTerminalOpenRequest={workspaceSshTerminalOpenRequest} workspaceSshTerminalOpen={workspaceSshTerminalOpen} diff --git a/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx b/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx index f3d1d882..bc165fac 100644 --- a/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx +++ b/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy } from "react"; import type { WorkspaceCodeEditorOpenRequest } from "@/components/workspace-editor/WorkspaceCodeEditorOverlay"; -import type { WorkspaceImagePreviewOpenRequest } from "@/components/workspace-editor/WorkspaceImagePreviewOverlay"; +import type { WorkspaceFilePreviewOpenRequest } from "@/components/workspace-editor/WorkspaceFilePreviewOverlay"; import type { WorkspaceSshTerminalOpenRequest } from "@/components/workspace-editor/WorkspaceSshTerminalOverlay"; import { t as translate } from "@/i18n"; import type { AppSettings } from "@/lib/settings"; @@ -18,10 +18,10 @@ const WorkspaceCodeEditorOverlay = lazy(async () => { }; }); -const WorkspaceImagePreviewOverlay = lazy(async () => { - const module = await import("@/components/workspace-editor/WorkspaceImagePreviewOverlay"); +const WorkspaceFilePreviewOverlay = lazy(async () => { + const module = await import("@/components/workspace-editor/WorkspaceFilePreviewOverlay"); return { - default: module.WorkspaceImagePreviewOverlay, + default: module.WorkspaceFilePreviewOverlay, }; }); @@ -40,13 +40,15 @@ type WorkspaceOverlayHostProps = { workspaceEditorCloseRequestId: number; workspaceEditorOpen: boolean; workspaceEditorCleanupPending: boolean; + onWorkspaceEditorPreviewFile: (request: WorkspaceCodeEditorOpenRequest) => void; onWorkspaceEditorHide: () => void; onWorkspaceEditorClose: () => void; - workspaceImagePreviewMounted: boolean; - workspaceImagePreviewOpenRequest: WorkspaceImagePreviewOpenRequest | null; - workspaceImagePreviewOpen: boolean; - onWorkspaceImagePreviewRequestClose: () => void; - onWorkspaceImagePreviewClose: () => void; + workspaceFilePreviewMounted: boolean; + workspaceFilePreviewOpenRequest: WorkspaceFilePreviewOpenRequest | null; + workspaceFilePreviewOpen: boolean; + onWorkspaceFilePreviewOpenEditor: (request: WorkspaceFilePreviewOpenRequest) => void; + onWorkspaceFilePreviewRequestClose: () => void; + onWorkspaceFilePreviewClose: () => void; workspaceSshTerminalMounted: boolean; workspaceSshTerminalOpenRequest: WorkspaceSshTerminalOpenRequest | null; workspaceSshTerminalOpen: boolean; @@ -65,13 +67,15 @@ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { workspaceEditorCloseRequestId, workspaceEditorOpen, workspaceEditorCleanupPending, + onWorkspaceEditorPreviewFile, onWorkspaceEditorHide, onWorkspaceEditorClose, - workspaceImagePreviewMounted, - workspaceImagePreviewOpenRequest, - workspaceImagePreviewOpen, - onWorkspaceImagePreviewRequestClose, - onWorkspaceImagePreviewClose, + workspaceFilePreviewMounted, + workspaceFilePreviewOpenRequest, + workspaceFilePreviewOpen, + onWorkspaceFilePreviewOpenEditor, + onWorkspaceFilePreviewRequestClose, + onWorkspaceFilePreviewClose, workspaceSshTerminalMounted, workspaceSshTerminalOpenRequest, workspaceSshTerminalOpen, @@ -97,24 +101,26 @@ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { isOpen={workspaceEditorOpen} finalCloseRequested={workspaceEditorCleanupPending} theme={theme} + onPreviewFile={onWorkspaceEditorPreviewFile} onHide={onWorkspaceEditorHide} onClose={onWorkspaceEditorClose} /> ) : null} - {workspaceImagePreviewMounted ? ( + {workspaceFilePreviewMounted ? ( - {translate("workspaceImagePreview.loading", locale)} +
+ {translate("workspaceFilePreview.loading", locale)}
} > -
) : null} diff --git a/crates/agent-gateway/web/src/app/hooks/useProjectToolsRuntime.ts b/crates/agent-gateway/web/src/app/hooks/useProjectToolsRuntime.ts index d52ca816..89962422 100644 --- a/crates/agent-gateway/web/src/app/hooks/useProjectToolsRuntime.ts +++ b/crates/agent-gateway/web/src/app/hooks/useProjectToolsRuntime.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { WorkspaceCodeEditorOpenRequest } from "@/components/workspace-editor/WorkspaceCodeEditorOverlay"; -import type { WorkspaceImagePreviewOpenRequest } from "@/components/workspace-editor/WorkspaceImagePreviewOverlay"; +import type { WorkspaceFilePreviewOpenRequest } from "@/components/workspace-editor/WorkspaceFilePreviewOverlay"; import type { WorkspaceSshTerminalOpenRequest } from "@/components/workspace-editor/WorkspaceSshTerminalOverlay"; -import { isWorkspaceImagePath } from "@/components/workspace-editor/workspaceImagePreview"; +import { isWorkspacePreviewPath } from "@/components/workspace-editor/workspaceImagePreview"; import { applyTerminalEventToSessions, replaceTerminalSessionsForProject, @@ -47,11 +47,11 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { useState(null); const [workspaceEditorCloseRequestId, setWorkspaceEditorCloseRequestId] = useState(0); const workspaceEditorRequestIdRef = useRef(0); - const [workspaceImagePreviewMounted, setWorkspaceImagePreviewMounted] = useState(false); - const [workspaceImagePreviewOpen, setWorkspaceImagePreviewOpen] = useState(false); - const [workspaceImagePreviewOpenRequest, setWorkspaceImagePreviewOpenRequest] = - useState(null); - const workspaceImagePreviewRequestIdRef = useRef(0); + const [workspaceFilePreviewMounted, setWorkspaceFilePreviewMounted] = useState(false); + const [workspaceFilePreviewOpen, setWorkspaceFilePreviewOpen] = useState(false); + const [workspaceFilePreviewOpenRequest, setWorkspaceFilePreviewOpenRequest] = + useState(null); + const workspaceFilePreviewRequestIdRef = useRef(0); const [workspaceSshTerminalMounted, setWorkspaceSshTerminalMounted] = useState(false); const [workspaceSshTerminalOpen, setWorkspaceSshTerminalOpen] = useState(false); const [workspaceSshTerminalOpenRequest, setWorkspaceSshTerminalOpenRequest] = @@ -67,7 +67,7 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { const openWorkspaceSshTerminalRequest = useCallback( (request: WorkspaceSshTerminalOpenRequest) => { - setWorkspaceImagePreviewOpen(false); + setWorkspaceFilePreviewOpen(false); setWorkspaceEditorOpen(false); setWorkspaceSshTerminalMounted(true); setWorkspaceSshTerminalOpen(true); @@ -92,35 +92,57 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { setWorkspaceEditorCloseRequestId(0); }, []); - const handleOpenWorkspaceFile = useCallback( - (path: string) => { - if (!terminalProjectPath || !terminalProjectPathKey) return; - if (isWorkspaceImagePath(path)) { - workspaceImagePreviewRequestIdRef.current += 1; - setWorkspaceImagePreviewMounted(true); - setWorkspaceImagePreviewOpen(true); - setWorkspaceImagePreviewOpenRequest({ - id: workspaceImagePreviewRequestIdRef.current, - projectPathKey: terminalProjectPathKey, - workdir: terminalProjectPath, - path, - }); - return; - } + const openWorkspaceEditorFile = useCallback( + (request: Omit) => { hideWorkspaceSshTerminalOverlay(); - setWorkspaceImagePreviewOpen(false); + setWorkspaceFilePreviewOpen(false); workspaceEditorRequestIdRef.current += 1; setWorkspaceEditorCleanupPending(false); setWorkspaceEditorMounted(true); setWorkspaceEditorOpen(true); setWorkspaceEditorOpenRequest({ id: workspaceEditorRequestIdRef.current, + ...request, + }); + }, + [hideWorkspaceSshTerminalOverlay], + ); + + const openWorkspaceFilePreview = useCallback( + (request: Omit) => { + hideWorkspaceSshTerminalOverlay(); + setWorkspaceEditorOpen(false); + workspaceFilePreviewRequestIdRef.current += 1; + setWorkspaceFilePreviewMounted(true); + setWorkspaceFilePreviewOpen(true); + setWorkspaceFilePreviewOpenRequest({ + id: workspaceFilePreviewRequestIdRef.current, + ...request, + }); + }, + [hideWorkspaceSshTerminalOverlay], + ); + + const handleOpenWorkspaceFile = useCallback( + (path: string) => { + if (!terminalProjectPath || !terminalProjectPathKey) return; + const request = { projectPathKey: terminalProjectPathKey, workdir: terminalProjectPath, path, - }); + }; + if (isWorkspacePreviewPath(path)) { + openWorkspaceFilePreview(request); + return; + } + openWorkspaceEditorFile(request); }, - [hideWorkspaceSshTerminalOverlay, terminalProjectPath, terminalProjectPathKey], + [ + openWorkspaceEditorFile, + openWorkspaceFilePreview, + terminalProjectPath, + terminalProjectPathKey, + ], ); const handleOpenSshTerminal = useCallback( @@ -136,14 +158,14 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { [openWorkspaceSshTerminalRequest], ); - const requestWorkspaceImagePreviewClose = useCallback(() => { - setWorkspaceImagePreviewOpen(false); + const requestWorkspaceFilePreviewClose = useCallback(() => { + setWorkspaceFilePreviewOpen(false); }, []); - const handleWorkspaceImagePreviewClosed = useCallback(() => { - setWorkspaceImagePreviewOpen(false); - setWorkspaceImagePreviewMounted(false); - setWorkspaceImagePreviewOpenRequest(null); + const handleWorkspaceFilePreviewClosed = useCallback(() => { + setWorkspaceFilePreviewOpen(false); + setWorkspaceFilePreviewMounted(false); + setWorkspaceFilePreviewOpenRequest(null); }, []); useEffect(() => { @@ -157,16 +179,16 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { setWorkspaceEditorOpen(true); requestWorkspaceEditorClose(); } - if (previousOpen && !rightDockFileTreeOpen && workspaceImagePreviewMounted) { - requestWorkspaceImagePreviewClose(); + if (previousOpen && !rightDockFileTreeOpen && workspaceFilePreviewMounted) { + requestWorkspaceFilePreviewClose(); } }, [ rightDockFileTreeOpen, requestWorkspaceEditorClose, - requestWorkspaceImagePreviewClose, + requestWorkspaceFilePreviewClose, workspaceEditorCleanupPending, workspaceEditorMounted, - workspaceImagePreviewMounted, + workspaceFilePreviewMounted, ]); const projectTerminalSessions = useMemo( @@ -294,9 +316,9 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { workspaceEditorCleanupPending, workspaceEditorOpenRequest, workspaceEditorCloseRequestId, - workspaceImagePreviewMounted, - workspaceImagePreviewOpen, - workspaceImagePreviewOpenRequest, + workspaceFilePreviewMounted, + workspaceFilePreviewOpen, + workspaceFilePreviewOpenRequest, workspaceSshTerminalMounted, workspaceSshTerminalOpen, workspaceSshTerminalOpenRequest, @@ -305,10 +327,12 @@ export function useProjectToolsRuntime(params: UseProjectToolsRuntimeParams) { terminalSessionsVersionRef, terminalStatusSessionIdRef, projectTerminalSessions, + openWorkspaceEditorFile, + openWorkspaceFilePreview, handleWorkspaceEditorHide, handleWorkspaceEditorClosed, - requestWorkspaceImagePreviewClose, - handleWorkspaceImagePreviewClosed, + requestWorkspaceFilePreviewClose, + handleWorkspaceFilePreviewClosed, handleOpenWorkspaceFile, handleOpenSshTerminal, handleProjectTerminalSessionsChange, diff --git a/crates/agent-gateway/web/src/components/chat/fileTypeIcons.tsx b/crates/agent-gateway/web/src/components/chat/fileTypeIcons.tsx index 563b0490..02d4db2b 100644 --- a/crates/agent-gateway/web/src/components/chat/fileTypeIcons.tsx +++ b/crates/agent-gateway/web/src/components/chat/fileTypeIcons.tsx @@ -167,6 +167,8 @@ const EXT_ICON: Record = { kts: FileTypeKotlin, dart: FileTypeDart, log: FileTypeLog, + csv: FileTypeExcel, + tsv: FileTypeExcel, xls: FileTypeExcel, xlsx: FileTypeExcel, doc: FileTypeWord, @@ -267,6 +269,8 @@ const EXT_ICON_SVG = { kts: FileTypeKotlinSvg, dart: FileTypeDartSvg, log: FileTypeLogSvg, + csv: FileTypeExcelSvg, + tsv: FileTypeExcelSvg, xls: FileTypeExcelSvg, xlsx: FileTypeExcelSvg, doc: FileTypeWordSvg, diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx index 21db0083..fb3e2169 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx @@ -7,16 +7,16 @@ import type { } from "@/lib/settings"; import { cn } from "@/lib/shared/utils"; import { getFileTypeIcon } from "../chat/fileTypeIcons"; -import { isWorkspaceImagePath } from "../workspace-editor/workspaceImagePreview"; +import { isWorkspacePreviewPath } from "../workspace-editor/workspaceImagePreview"; import { Check, ChevronRight, Copy, Edit3, + Eye, FilePenLine, Folder, FolderOpen, - ImageIcon, Loader2, Plus, RefreshCw, @@ -69,6 +69,7 @@ type ContextMenuState = { const ROOT_PATH = ""; const DEFAULT_MAX_RESULTS = 1000; const SEARCH_MAX_RESULTS = 80; +const FILE_TREE_AUTO_REFRESH_MS = 3000; function basename(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); @@ -187,11 +188,21 @@ export function ProjectFileTreePanel(props: { const selectedNode = state.nodes[state.selectedPath] ?? state.nodes[ROOT_PATH]; const selectedPath = selectedNode?.path ?? ROOT_PATH; const canMutate = initialized && Boolean(projectPathKey && cwd); + const stateRef = useRef(state); + const queryRef = useRef(query); useEffect(() => { onSyncStateChangeRef.current = onSyncStateChange; }, [onSyncStateChange]); + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + queryRef.current = query; + }, [query]); + const setProjectState = useCallback( (updater: (state: FileTreeState) => FileTreeState) => { if (!projectPathKey) return; @@ -211,7 +222,7 @@ export function ProjectFileTreePanel(props: { }, []); const loadChildren = useCallback( - async (path: string, options?: { force?: boolean }) => { + async (path: string, options?: { force?: boolean; silent?: boolean }) => { if (!projectPathKey || !cwd.trim()) return; let shouldLoad = true; setProjectState((current) => { @@ -220,6 +231,10 @@ export function ProjectFileTreePanel(props: { shouldLoad = false; return current; } + if (node.loading && options?.silent) { + shouldLoad = false; + return current; + } if ((node.loaded || node.loading) && !options?.force) { shouldLoad = false; return current; @@ -229,7 +244,11 @@ export function ProjectFileTreePanel(props: { initialized: true, nodes: { ...current.nodes, - [path]: { ...node, loading: true, error: undefined }, + [path]: { + ...node, + loading: options?.silent ? node.loading : true, + error: options?.silent ? node.error : undefined, + }, }, }; }); @@ -284,7 +303,9 @@ export function ProjectFileTreePanel(props: { [path]: { ...node, loading: false, - error: toErrorMessage(error, t("projectTools.fileTree.readFailed")), + error: options?.silent + ? node.error + : toErrorMessage(error, t("projectTools.fileTree.readFailed")), }, }, }; @@ -348,6 +369,26 @@ export function ProjectFileTreePanel(props: { void loadChildren(ROOT_PATH); }, [initialized, loadChildren, projectPathKey]); + useEffect(() => { + if (!initialized || !projectPathKey || !cwd.trim()) return; + const interval = window.setInterval(() => { + const snapshot = stateRef.current; + const pathsToReload = Array.from(new Set([ROOT_PATH, ...snapshot.expanded])).filter( + (path) => { + const node = snapshot.nodes[path]; + return node?.kind === "dir" && node.loaded; + }, + ); + for (const path of pathsToReload) { + void loadChildren(path, { force: true, silent: true }); + } + if (queryRef.current.trim()) { + setSearchRefreshKey((current) => current + 1); + } + }, FILE_TREE_AUTO_REFRESH_MS); + return () => window.clearInterval(interval); + }, [cwd, initialized, loadChildren, projectPathKey]); + useEffect(() => { void projectPathKey; setContextMenu(null); @@ -967,14 +1008,14 @@ export function ProjectFileTreePanel(props: { setContextMenu(null); }} > - {isWorkspaceImagePath(contextPath) ? ( - + {isWorkspacePreviewPath(contextPath) ? ( + ) : ( )} {t( - isWorkspaceImagePath(contextPath) - ? "projectTools.fileTree.previewImage" + isWorkspacePreviewPath(contextPath) + ? "projectTools.fileTree.previewFile" : "projectTools.fileTree.openFile", )} diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index 95e6d6cf..90631f31 100644 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -20,6 +20,7 @@ import { AlertTriangle, ClipboardPaste, Copy, + Eye, FilePenLine, Loader2, Redo2, @@ -33,6 +34,7 @@ import { X, } from "../icons"; import type { IconComponent } from "../icons"; +import { isWorkspacePreviewPath } from "./workspaceImagePreview"; type MonacoEnvironmentGlobal = typeof globalThis & { MonacoEnvironment?: { @@ -117,6 +119,7 @@ type WorkspaceCodeEditorOverlayProps = { isOpen: boolean; finalCloseRequested?: boolean; theme: "light" | "dark"; + onPreviewFile: (request: WorkspaceCodeEditorOpenRequest) => void; onHide: () => void; onClose: () => void; }; @@ -277,6 +280,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp isOpen, finalCloseRequested = false, theme, + onPreviewFile, onHide, onClose, } = props; @@ -306,6 +310,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp () => tabs.find((tab) => tab.key === activeKey) ?? tabs[0] ?? null, [activeKey, tabs], ); + const canPreviewActiveTab = Boolean(activeTab && isWorkspacePreviewPath(activeTab.path)); const dirtyTabs = useMemo(() => tabs.filter((tab) => tab.content !== tab.savedContent), [tabs]); const hasDirtyTabs = dirtyTabs.length > 0; const isOpening = openingPaths.length > 0; @@ -859,6 +864,21 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp > + {canPreviewActiveTab && activeTab ? ( + + onPreviewFile({ + id: Date.now(), + projectPathKey: activeTab.projectPathKey, + workdir: activeTab.workdir, + path: activeTab.path, + }) + } + > + + + ) : null} diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx new file mode 100644 index 00000000..fd56ae57 --- /dev/null +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -0,0 +1,623 @@ +import { invoke } from "@tauri-apps/api/core"; +import { renderAsync } from "docx-preview"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { read, utils } from "xlsx"; +import { useLocale } from "@/i18n"; +import { cn } from "@/lib/shared/utils"; +import type { IconComponent } from "../icons"; +import { + AlertTriangle, + FilePenLine, + FileText, + FileTypeAudio, + FileTypeExcel, + FileTypeHtml, + FileTypeMarkdown, + FileTypePdf, + FileTypeVideo, + FileTypeWord, + ImageIcon, + Loader2, + RefreshCw, + X, +} from "../icons"; +import { Markdown } from "@/components/Markdown"; +import { + getWorkspacePreviewKind, + isWorkspaceEditablePreviewPath, + type WorkspacePreviewKind, +} from "./workspaceImagePreview"; + +export type WorkspaceFilePreviewOpenRequest = { + id: number; + projectPathKey: string; + workdir: string; + path: string; +}; + +type ReadWorkspacePreviewResponse = { + path: string; + mimeType: string; + data: string; + sizeBytes: number; + mtimeMs: number; + contentHash: string; +}; + +type WorkspaceFilePreviewOverlayProps = { + openRequest: WorkspaceFilePreviewOpenRequest | null; + isOpen: boolean; + onOpenEditor: (request: WorkspaceFilePreviewOpenRequest) => void; + onRequestClose: () => void; + onClose: () => void; +}; + +type LoadedPreview = ReadWorkspacePreviewResponse & { + blobUrl: string; + bytes: Uint8Array; + kind: WorkspacePreviewKind; + text: string | null; +}; + +type SpreadsheetTable = { + sheetNames: string[]; + rows: Array<{ + id: string; + cells: Array<{ id: string; value: string }>; + }>; + activeSheetName: string; + truncatedRows: boolean; + truncatedColumns: boolean; + error: string | null; +}; + +const FILE_PREVIEW_OVERLAY_ANIMATION_MS = 180; +const SPREADSHEET_MAX_ROWS = 250; +const SPREADSHEET_MAX_COLUMNS = 80; + +function basename(path: string) { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) return error.message; + const text = String(error ?? "").trim(); + return text || fallback; +} + +function base64ToBytes(data: string) { + const binary = window.atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer; +} + +function isTextPreviewKind(kind: WorkspacePreviewKind) { + return kind === "html" || kind === "markdown" || kind === "text"; +} + +function kindFromMimeType(mimeType: string): WorkspacePreviewKind | null { + const mime = mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; + if (mime.startsWith("image/")) return "image"; + if (mime === "application/pdf") return "pdf"; + if (mime === "text/html") return "html"; + if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown"; + if ( + mime === "text/csv" || + mime === "text/tab-separated-values" || + mime.includes("spreadsheet") || + mime.includes("excel") || + mime === "application/vnd.oasis.opendocument.spreadsheet" + ) { + return "spreadsheet"; + } + if (mime.includes("wordprocessingml")) return "document"; + if (mime.startsWith("audio/")) return "audio"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("text/")) return "text"; + return null; +} + +function resolvePreviewKind(path: string, mimeType: string): WorkspacePreviewKind { + const mimeKind = kindFromMimeType(mimeType); + if (mimeKind === "html" || mimeKind === "markdown" || mimeKind === "text") return mimeKind; + return getWorkspacePreviewKind(path) ?? mimeKind ?? "text"; +} + +function decodePreviewText(bytes: Uint8Array) { + return new TextDecoder("utf-8").decode(bytes); +} + +function hashString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(36); +} + +function getPreviewIcon(kind: WorkspacePreviewKind): IconComponent { + switch (kind) { + case "audio": + return FileTypeAudio; + case "document": + return FileTypeWord; + case "html": + return FileTypeHtml; + case "image": + return ImageIcon; + case "markdown": + return FileTypeMarkdown; + case "pdf": + return FileTypePdf; + case "spreadsheet": + return FileTypeExcel; + case "video": + return FileTypeVideo; + case "text": + return FileText; + } +} + +function buildSpreadsheetTable( + preview: LoadedPreview | null, + activeSheetName: string, + fallbackError: string, +): SpreadsheetTable | null { + if (!preview || preview.kind !== "spreadsheet") return null; + try { + const workbook = read(preview.bytes, { type: "array", cellDates: true }); + const sheetNames = workbook.SheetNames; + const selectedSheetName = + sheetNames.find((name) => name === activeSheetName) ?? sheetNames[0] ?? ""; + const sheet = selectedSheetName ? workbook.Sheets[selectedSheetName] : null; + if (!sheet) { + return { + sheetNames, + rows: [], + activeSheetName: selectedSheetName, + truncatedRows: false, + truncatedColumns: false, + error: null, + }; + } + const rawRows = utils.sheet_to_json(sheet, { + header: 1, + blankrows: false, + defval: "", + raw: false, + }); + const maxColumns = rawRows.reduce( + (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), + 0, + ); + const rows = rawRows.slice(0, SPREADSHEET_MAX_ROWS).map((row, rowIndex) => { + const cells = Array.from( + { length: Math.min(maxColumns, SPREADSHEET_MAX_COLUMNS) }, + (_, index) => ({ + id: `c${index}`, + value: String(Array.isArray(row) ? (row[index] ?? "") : ""), + }), + ); + return { + id: `r${rowIndex}-${hashString(cells.map((cell) => cell.value).join("\u0000"))}`, + cells, + }; + }); + return { + sheetNames, + rows, + activeSheetName: selectedSheetName, + truncatedRows: rawRows.length > SPREADSHEET_MAX_ROWS, + truncatedColumns: maxColumns > SPREADSHEET_MAX_COLUMNS, + error: null, + }; + } catch (error) { + return { + sheetNames: [], + rows: [], + activeSheetName: "", + truncatedRows: false, + truncatedColumns: false, + error: toMessage(error, fallbackError), + }; + } +} + +export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayProps) { + const { openRequest, isOpen, onOpenEditor, onRequestClose, onClose } = props; + const { t } = useLocale(); + const closeAnimationTimeoutRef = useRef(null); + const loadSequenceRef = useRef(0); + const previewBlobUrlRef = useRef(null); + const [preview, setPreview] = useState(null); + const [activeSheetName, setActiveSheetName] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [renderError, setRenderError] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const replacePreview = useCallback((next: LoadedPreview | null) => { + if (previewBlobUrlRef.current) { + URL.revokeObjectURL(previewBlobUrlRef.current); + } + previewBlobUrlRef.current = next?.blobUrl ?? null; + setActiveSheetName(""); + setPreview(next); + }, []); + + useEffect( + () => () => { + if (previewBlobUrlRef.current) { + URL.revokeObjectURL(previewBlobUrlRef.current); + previewBlobUrlRef.current = null; + } + }, + [], + ); + + useEffect(() => { + if (isOpen) { + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + closeAnimationTimeoutRef.current = null; + } + const animationFrame = window.requestAnimationFrame(() => setIsVisible(true)); + return () => window.cancelAnimationFrame(animationFrame); + } + + setIsVisible(false); + closeAnimationTimeoutRef.current = window.setTimeout(() => { + closeAnimationTimeoutRef.current = null; + onClose(); + }, FILE_PREVIEW_OVERLAY_ANIMATION_MS); + }, [isOpen, onClose]); + + useEffect( + () => () => { + if (closeAnimationTimeoutRef.current !== null) { + window.clearTimeout(closeAnimationTimeoutRef.current); + } + }, + [], + ); + + const loadPreview = useCallback( + async (request: WorkspaceFilePreviewOpenRequest) => { + const sequence = loadSequenceRef.current + 1; + loadSequenceRef.current = sequence; + setLoading(true); + setError(null); + setRenderError(null); + replacePreview(null); + try { + const response = await invoke("fs_read_workspace_image", { + workdir: request.workdir, + path: request.path, + }); + if (loadSequenceRef.current !== sequence) return; + const bytes = base64ToBytes(response.data); + const kind = resolvePreviewKind(response.path || request.path, response.mimeType); + const blob = new Blob([bytesToArrayBuffer(bytes)], { type: response.mimeType }); + const loaded: LoadedPreview = { + ...response, + blobUrl: URL.createObjectURL(blob), + bytes, + kind, + text: isTextPreviewKind(kind) ? decodePreviewText(bytes) : null, + }; + replacePreview(loaded); + } catch (loadError) { + if (loadSequenceRef.current !== sequence) return; + replacePreview(null); + setError(toMessage(loadError, t("workspaceFilePreview.openFailed"))); + } finally { + if (loadSequenceRef.current === sequence) { + setLoading(false); + } + } + }, + [replacePreview, t], + ); + + useEffect(() => { + if (!openRequest) return; + void loadPreview(openRequest); + }, [loadPreview, openRequest]); + + const spreadsheet = useMemo( + () => buildSpreadsheetTable(preview, activeSheetName, t("workspaceFilePreview.renderFailed")), + [activeSheetName, preview, t], + ); + + useEffect(() => { + if (!spreadsheet?.activeSheetName) return; + setActiveSheetName((current) => current || spreadsheet.activeSheetName); + }, [spreadsheet?.activeSheetName]); + + const activePath = preview?.path ?? openRequest?.path ?? ""; + const kind = preview?.kind ?? (activePath ? getWorkspacePreviewKind(activePath) : null) ?? "text"; + const PreviewIcon = getPreviewIcon(kind); + const canOpenEditor = Boolean(openRequest && isWorkspaceEditablePreviewPath(activePath)); + + return ( +
+
+ +
+
+ {t("workspaceFilePreview.title")} +
+
{activePath}
+
+
+ {canOpenEditor && openRequest ? ( + + ) : null} + + +
+
+ + {error || renderError || spreadsheet?.error ? ( +
+ +
+ {error ?? renderError ?? spreadsheet?.error} +
+
+ ) : null} + +
+ {loading ? ( +
+ +
+ ) : preview ? ( + + ) : ( +
+ + {t("workspaceFilePreview.empty")} +
+ )} +
+ +
+ {activePath} + {preview ? ( + + {preview.mimeType} · {formatBytes(preview.sizeBytes)} + + ) : null} +
+
+ ); +} + +function PreviewBody(props: { + preview: LoadedPreview; + spreadsheet: SpreadsheetTable | null; + activeSheetName: string; + onActiveSheetNameChange: (sheetName: string) => void; + onRenderError: (message: string | null) => void; +}) { + const { preview, spreadsheet, activeSheetName, onActiveSheetNameChange, onRenderError } = props; + const { t } = useLocale(); + const docxContainerRef = useRef(null); + + useEffect(() => { + if (preview.kind !== "document") return; + const container = docxContainerRef.current; + if (!container) return; + let cancelled = false; + container.innerHTML = ""; + onRenderError(null); + void renderAsync(bytesToArrayBuffer(preview.bytes), container, undefined, { + className: "workspace-docx-preview", + inWrapper: true, + ignoreFonts: false, + breakPages: true, + useBase64URL: true, + }).catch((docxError) => { + if (!cancelled) { + onRenderError(toMessage(docxError, t("workspaceFilePreview.renderFailed"))); + } + }); + return () => { + cancelled = true; + container.innerHTML = ""; + }; + }, [onRenderError, preview, t]); + + if (preview.kind === "image") { + return ( +
+ {basename(preview.path)} +
+ ); + } + + if (preview.kind === "pdf") { + return ( +