Add syntax highlighting to file browser, harden capsules list
File browser: - Add shiki-based syntax highlighting (lazy-loaded, zero initial bundle impact) with support for 30+ languages - Cap highlighting at 2000 lines to avoid freezing on large files - Pre-compute preview lines as derived state instead of re-splitting on every render - Add content-visibility: auto on code lines for off-screen skip - Remove per-line CSS transitions (unnecessary paint on 5000 elements) - Cap row entrance animations to first 30 entries Capsules list: - Pause auto-refresh polling when browser tab is hidden - Add empty state for search with no results - Fix error state not clearing on successful refresh - Fix action menu positioning near viewport edges - Disable create button when no template selected
This commit is contained in:
@ -31,6 +31,7 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chart.js": "^4.5.1"
|
||||
"chart.js": "^4.5.1",
|
||||
"shiki": "^4.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
331
frontend/pnpm-lock.yaml
generated
331
frontend/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ importers:
|
||||
chart.js:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
shiki:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
devDependencies:
|
||||
'@fontsource-variable/jetbrains-mono':
|
||||
specifier: ^5.2.8
|
||||
@ -393,6 +396,37 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@shikijs/core@4.0.2':
|
||||
resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.0.2':
|
||||
resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.0.2':
|
||||
resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.0.2':
|
||||
resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.0.2':
|
||||
resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.0.2':
|
||||
resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.0.2':
|
||||
resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@ -536,13 +570,25 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@typescript-eslint/types@8.57.1':
|
||||
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
@ -572,6 +618,15 @@ packages:
|
||||
'@internationalized/date': ^3.8.1
|
||||
svelte: ^5.33.0
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
character-entities-legacy@3.0.0:
|
||||
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
|
||||
|
||||
chart.js@4.5.1:
|
||||
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||
engines: {pnpm: '>=8'}
|
||||
@ -584,6 +639,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
cookie@0.6.0:
|
||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -603,6 +661,9 @@ packages:
|
||||
devalue@5.6.4:
|
||||
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@ -635,6 +696,15 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
@ -729,6 +799,24 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
|
||||
|
||||
micromark-util-encode@2.0.1:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
|
||||
|
||||
micromark-util-symbol@2.0.1:
|
||||
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
|
||||
|
||||
micromark-util-types@2.0.2:
|
||||
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@ -745,6 +833,12 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
oniguruma-parser@0.12.1:
|
||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -756,10 +850,22 @@ packages:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
rollup@4.59.0:
|
||||
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@ -781,6 +887,10 @@ packages:
|
||||
set-cookie-parser@3.0.1:
|
||||
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
||||
|
||||
shiki@4.0.2:
|
||||
resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
sirv@3.0.2:
|
||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||
engines: {node: '>=18'}
|
||||
@ -789,6 +899,12 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
@ -828,6 +944,9 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -836,6 +955,27 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -887,6 +1027,9 @@ packages:
|
||||
zimmerframe@1.1.4:
|
||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
@ -1088,6 +1231,46 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.0.2
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.5
|
||||
|
||||
'@shikijs/engine-oniguruma@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
|
||||
'@shikijs/primitive@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
|
||||
'@shikijs/types@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)':
|
||||
@ -1211,10 +1394,22 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@typescript-eslint/types@8.57.1': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/addon-web-links@0.12.0': {}
|
||||
@ -1240,6 +1435,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
|
||||
chart.js@4.5.1:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
@ -1250,6 +1451,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
cookie@0.6.0: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@ -1260,6 +1463,10 @@ snapshots:
|
||||
|
||||
devalue@5.6.4: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
enhanced-resolve@5.20.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -1310,6 +1517,26 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
is-reference@3.0.3:
|
||||
@ -1377,6 +1604,35 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
trim-lines: 3.0.1
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-util-encode@2.0.1: {}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-symbol@2.0.1: {}
|
||||
|
||||
micromark-util-types@2.0.2: {}
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
@ -1385,6 +1641,14 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
oniguruma-parser@0.12.1: {}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.1
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@ -1395,8 +1659,20 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rollup@4.59.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@ -1443,6 +1719,17 @@ snapshots:
|
||||
|
||||
set-cookie-parser@3.0.1: {}
|
||||
|
||||
shiki@4.0.2:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.0.2
|
||||
'@shikijs/engine-javascript': 4.0.2
|
||||
'@shikijs/engine-oniguruma': 4.0.2
|
||||
'@shikijs/langs': 4.0.2
|
||||
'@shikijs/themes': 4.0.2
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
sirv@3.0.2:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
@ -1451,6 +1738,13 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
character-entities-legacy: 3.0.0
|
||||
|
||||
style-to-object@1.0.14:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
@ -1508,10 +1802,45 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-stringify-position: 4.0.0
|
||||
|
||||
vfile@6.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
@ -1530,3 +1859,5 @@ snapshots:
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
|
||||
|
||||
type Props = {
|
||||
@ -292,7 +291,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCreate}
|
||||
disabled={creating}
|
||||
disabled={creating || !templateQuery.trim()}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if creating}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
formatFileSize,
|
||||
type FileEntry,
|
||||
} from '$lib/api/files';
|
||||
import { tokenize, type ThemedToken } from '$lib/highlight';
|
||||
|
||||
type Props = {
|
||||
sandboxId: string;
|
||||
@ -29,17 +30,33 @@
|
||||
let fileError = $state<string | null>(null);
|
||||
let downloading = $state(false);
|
||||
|
||||
// Syntax highlighting (lazy — loaded on first use)
|
||||
let highlightedTokens = $state<ThemedToken[][] | null>(null);
|
||||
|
||||
// Request generation counters — discard stale responses from rapid clicks
|
||||
let dirGeneration = 0;
|
||||
let fileGeneration = 0;
|
||||
|
||||
const MAX_PREVIEW_LINES = 5000;
|
||||
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
|
||||
|
||||
// Path input
|
||||
let pathInput = $state('~');
|
||||
let pathInputFocused = $state(false);
|
||||
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
||||
// Pre-computed preview lines — avoids re-splitting on every render
|
||||
const previewLines = $derived.by(() => {
|
||||
if (!fileContent) return { lines: [] as string[], truncated: false, totalLines: 0 };
|
||||
const allLines = fileContent.split('\n');
|
||||
const truncated = allLines.length > MAX_PREVIEW_LINES;
|
||||
return {
|
||||
lines: truncated ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines,
|
||||
truncated,
|
||||
totalLines: allLines.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Sorted entries: directories first, then files, alphabetical within each group
|
||||
const sortedEntries = $derived(
|
||||
[...entries].sort((a, b) => {
|
||||
@ -71,6 +88,7 @@
|
||||
selectedFile = null;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
highlightedTokens = null;
|
||||
await loadDir();
|
||||
}
|
||||
|
||||
@ -131,6 +149,7 @@
|
||||
selectedFile = entry;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
highlightedTokens = null;
|
||||
|
||||
// Check if we should preview or prompt download
|
||||
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
|
||||
@ -147,6 +166,14 @@
|
||||
fileContent = null;
|
||||
} else {
|
||||
fileContent = result.data;
|
||||
// Kick off highlighting in the background — preview shows plain text immediately.
|
||||
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
|
||||
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
|
||||
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
|
||||
: result.data;
|
||||
tokenize(linesToHighlight, entry.name).then((tokens) => {
|
||||
if (gen === fileGeneration) highlightedTokens = tokens;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fileError = result.error;
|
||||
@ -252,6 +279,71 @@
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Extension → color mapping for file icons and badges
|
||||
function extColor(name: string): string {
|
||||
const ext = fileExt(name);
|
||||
switch (ext) {
|
||||
case 'go': case 'mod': case 'sum':
|
||||
return '#5a9fd4'; // blue — Go
|
||||
case 'py': case 'pyi': case 'pyx':
|
||||
return '#d4a73c'; // amber — Python
|
||||
case 'js': case 'mjs': case 'cjs':
|
||||
return '#d4a73c'; // amber — JavaScript
|
||||
case 'ts': case 'mts': case 'cts': case 'tsx': case 'jsx':
|
||||
return '#5a9fd4'; // blue — TypeScript/React
|
||||
case 'rs':
|
||||
return '#cf8172'; // red — Rust
|
||||
case 'sh': case 'bash': case 'zsh': case 'fish':
|
||||
return '#5e8c58'; // accent — shell
|
||||
case 'json': case 'yaml': case 'yml': case 'toml': case 'ini': case 'env':
|
||||
return '#8b7ec8'; // purple — config
|
||||
case 'md': case 'mdx': case 'txt': case 'rst':
|
||||
return 'var(--color-text-secondary)'; // neutral — docs
|
||||
case 'sql':
|
||||
return '#5a9fd4'; // blue — SQL
|
||||
case 'proto':
|
||||
return '#5e8c58'; // accent — protobuf
|
||||
case 'svelte': case 'vue':
|
||||
return '#cf8172'; // red — Svelte/Vue
|
||||
case 'css': case 'scss': case 'less':
|
||||
return '#5a9fd4'; // blue — styles
|
||||
case 'html': case 'htm':
|
||||
return '#cf8172'; // red — HTML
|
||||
case 'dockerfile': case 'makefile':
|
||||
return '#5e8c58'; // accent — build
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
// Descriptive label for file type badge in preview header
|
||||
function extLabel(name: string): string {
|
||||
const ext = fileExt(name);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'makefile') return 'Make';
|
||||
if (lower === 'dockerfile') return 'Docker';
|
||||
switch (ext) {
|
||||
case 'go': return 'Go';
|
||||
case 'py': return 'Python';
|
||||
case 'js': case 'mjs': case 'cjs': return 'JS';
|
||||
case 'ts': case 'mts': case 'cts': return 'TS';
|
||||
case 'tsx': return 'TSX';
|
||||
case 'jsx': return 'JSX';
|
||||
case 'rs': return 'Rust';
|
||||
case 'sh': case 'bash': return 'Shell';
|
||||
case 'json': return 'JSON';
|
||||
case 'yaml': case 'yml': return 'YAML';
|
||||
case 'toml': return 'TOML';
|
||||
case 'sql': return 'SQL';
|
||||
case 'proto': return 'Proto';
|
||||
case 'svelte': return 'Svelte';
|
||||
case 'css': return 'CSS';
|
||||
case 'html': case 'htm': return 'HTML';
|
||||
case 'md': case 'mdx': return 'Markdown';
|
||||
default: return ext ? ext.toUpperCase() : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial directory on mount, falling back to / if home can't be resolved
|
||||
let hasInitiallyLoaded = false;
|
||||
$effect(() => {
|
||||
@ -277,10 +369,11 @@
|
||||
}
|
||||
.file-row.active {
|
||||
background-color: var(--color-accent-glow);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
box-shadow: inset 0 0 20px rgba(94, 140, 88, 0.06);
|
||||
}
|
||||
.file-row:not(.active) {
|
||||
border-left: 2px solid transparent;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
@ -288,6 +381,12 @@
|
||||
-moz-tab-size: 4;
|
||||
}
|
||||
|
||||
/* Let the browser skip rendering off-screen lines in long files */
|
||||
.code-line {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 1.65rem;
|
||||
}
|
||||
|
||||
/* Staggered row entrance */
|
||||
@keyframes rowSlideIn {
|
||||
from { opacity: 0; transform: translateX(-4px); }
|
||||
@ -436,9 +535,10 @@
|
||||
{#each sortedEntries as entry, idx (entry.path)}
|
||||
<button
|
||||
onclick={() => selectFile(entry)}
|
||||
class="file-row row-enter flex w-full items-center gap-3 px-4 py-[7px] text-left
|
||||
{selectedFile?.path === entry.path ? 'active' : ''}"
|
||||
style="animation-delay: {Math.min(idx * 12, 200)}ms"
|
||||
class="file-row flex w-full items-center gap-3 px-4 py-[7px] text-left
|
||||
{selectedFile?.path === entry.path ? 'active' : ''}
|
||||
{idx < 30 ? 'row-enter' : ''}"
|
||||
style={idx < 30 ? `animation-delay: ${idx * 12}ms` : undefined}
|
||||
>
|
||||
<!-- Icon -->
|
||||
{#if fileIcon(entry) === 'dir'}
|
||||
@ -451,7 +551,7 @@
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="shrink-0" style="color: {extColor(entry.name)}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
@ -461,7 +561,7 @@
|
||||
<div class="flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<span class="truncate font-mono text-meta
|
||||
{entry.type === 'directory'
|
||||
? 'text-[var(--color-text-primary)]'
|
||||
? 'text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)]'}">
|
||||
{entry.name}
|
||||
</span>
|
||||
@ -472,8 +572,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Size (files only) -->
|
||||
<!-- Size + extension hint (files only) -->
|
||||
{#if entry.type === 'file'}
|
||||
{#if fileExt(entry.name)}
|
||||
<span class="shrink-0 font-mono text-[9px] uppercase tracking-[0.05em]" style="color: {extColor(entry.name)}; opacity: 0.7">
|
||||
{fileExt(entry.name)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
|
||||
{formatFileSize(entry.size)}
|
||||
</span>
|
||||
@ -533,15 +638,18 @@
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="shrink-0" style="color: {extColor(selectedFile.name)}" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
|
||||
{#if fileExt(selectedFile.name)}
|
||||
<span class="shrink-0 rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-badge uppercase text-[var(--color-text-muted)]">
|
||||
{fileExt(selectedFile.name)}
|
||||
{#if extLabel(selectedFile.name)}
|
||||
<span
|
||||
class="shrink-0 rounded-[3px] border px-1.5 py-0.5 font-mono text-badge font-semibold uppercase tracking-[0.03em]"
|
||||
style="color: {extColor(selectedFile.name)}; border-color: color-mix(in srgb, {extColor(selectedFile.name)} 25%, transparent); background: color-mix(in srgb, {extColor(selectedFile.name)} 8%, transparent)"
|
||||
>
|
||||
{extLabel(selectedFile.name)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@ -632,16 +740,13 @@
|
||||
</div>
|
||||
{:else if fileContent !== null}
|
||||
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
|
||||
{@const allLines = fileContent.split('\n')}
|
||||
{@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines}
|
||||
{@const truncated = allLines.length > MAX_PREVIEW_LINES}
|
||||
<div style="animation: fadeUp 0.15s ease both">
|
||||
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
|
||||
<pre class="preview-code p-0 m-0"><code class="block">{#each previewLines.lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)]">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem]">{#if highlightedTokens && highlightedTokens[i]}{#each highlightedTokens[i] as token}<span style="color: {token.color ?? 'var(--color-text-secondary)'}">{token.content}</span>{/each}{:else}<span class="text-[var(--color-text-secondary)]">{line || ' '}</span>{/if}</span></div>{/each}</code></pre>
|
||||
</div>
|
||||
{#if truncated}
|
||||
{#if previewLines.truncated}
|
||||
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
|
||||
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {previewLines.totalLines.toLocaleString()} lines
|
||||
</span>
|
||||
<button
|
||||
onclick={handleDownload}
|
||||
|
||||
128
frontend/src/lib/highlight.ts
Normal file
128
frontend/src/lib/highlight.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Lazy syntax highlighting via shiki.
|
||||
*
|
||||
* The highlighter WASM engine + theme are loaded on first use.
|
||||
* Language grammars load on-demand per extension.
|
||||
* All imports are dynamic so nothing touches the main bundle.
|
||||
*/
|
||||
|
||||
import type { HighlighterGeneric, ThemedToken } from 'shiki';
|
||||
export type { ThemedToken };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let highlighter: HighlighterGeneric<any, any> | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
|
||||
|
||||
const THEME = 'vesper';
|
||||
|
||||
// Extensions → shiki language IDs.
|
||||
// Only map what we expect users to encounter in sandboxes.
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
// Go
|
||||
go: 'go', mod: 'go', sum: 'go',
|
||||
// Python
|
||||
py: 'python', pyi: 'python', pyx: 'python',
|
||||
// JavaScript / TypeScript
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
|
||||
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
|
||||
// Rust
|
||||
rs: 'rust',
|
||||
// Shell
|
||||
sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript',
|
||||
// Config
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
|
||||
env: 'shellscript',
|
||||
// Markup / docs
|
||||
md: 'markdown', mdx: 'mdx', html: 'html', htm: 'html', xml: 'xml',
|
||||
// CSS
|
||||
css: 'css', scss: 'scss', less: 'less',
|
||||
// SQL
|
||||
sql: 'sql',
|
||||
// Svelte / Vue
|
||||
svelte: 'svelte', vue: 'vue',
|
||||
// Docker / Make
|
||||
dockerfile: 'dockerfile',
|
||||
makefile: 'makefile',
|
||||
// Proto
|
||||
proto: 'protobuf',
|
||||
// C / C++
|
||||
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
||||
// Java / Kotlin
|
||||
java: 'java', kt: 'kotlin', kts: 'kotlin',
|
||||
// Ruby
|
||||
rb: 'ruby',
|
||||
// PHP
|
||||
php: 'php',
|
||||
// Lua
|
||||
lua: 'lua',
|
||||
// Misc
|
||||
txt: 'plaintext',
|
||||
};
|
||||
|
||||
// Filenames without extensions
|
||||
const NAME_TO_LANG: Record<string, string> = {
|
||||
Dockerfile: 'dockerfile',
|
||||
Makefile: 'makefile',
|
||||
Containerfile: 'dockerfile',
|
||||
Vagrantfile: 'ruby',
|
||||
};
|
||||
|
||||
/** Resolve a filename to a shiki language ID, or null if unknown. */
|
||||
export function langFromFilename(name: string): string | null {
|
||||
// Check full filename first (Dockerfile, Makefile, etc.)
|
||||
const basename = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
|
||||
if (NAME_TO_LANG[basename]) return NAME_TO_LANG[basename];
|
||||
|
||||
const dot = basename.lastIndexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
const ext = basename.slice(dot + 1).toLowerCase();
|
||||
return EXT_TO_LANG[ext] ?? null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
|
||||
if (highlighter) return highlighter;
|
||||
if (loadingPromise) return loadingPromise;
|
||||
|
||||
loadingPromise = (async () => {
|
||||
const { createHighlighter } = await import('shiki');
|
||||
|
||||
const h = await createHighlighter({
|
||||
themes: [THEME],
|
||||
langs: [], // load languages on demand
|
||||
});
|
||||
highlighter = h;
|
||||
return h;
|
||||
})();
|
||||
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize code for a given language.
|
||||
* Returns an array of lines, each containing themed tokens with `color` and `content`.
|
||||
* Returns null if the language is unknown or highlighting fails.
|
||||
*/
|
||||
export async function tokenize(
|
||||
code: string,
|
||||
filename: string,
|
||||
): Promise<ThemedToken[][] | null> {
|
||||
const lang = langFromFilename(filename);
|
||||
if (!lang || lang === 'plaintext') return null;
|
||||
|
||||
try {
|
||||
const h = await getHighlighter();
|
||||
|
||||
// Load grammar on demand if not yet loaded
|
||||
const loaded = h.getLoadedLanguages();
|
||||
if (!loaded.includes(lang)) {
|
||||
await h.loadLanguage(lang);
|
||||
}
|
||||
|
||||
return h.codeToTokensBase(code, { lang, theme: THEME });
|
||||
} catch {
|
||||
// Grammar not available or other error — fall back to plain text
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -132,6 +132,9 @@
|
||||
const result = await listCapsules();
|
||||
if (result.ok) {
|
||||
capsules = result.data;
|
||||
error = null;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
|
||||
@ -236,10 +239,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
if (document.hidden) {
|
||||
stopAutoRefresh();
|
||||
} else if (autoRefresh) {
|
||||
fetchCapsules();
|
||||
startAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchCapsules();
|
||||
startAutoRefresh();
|
||||
return () => stopAutoRefresh();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => {
|
||||
stopAutoRefresh();
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -376,7 +392,31 @@
|
||||
Loading capsules...
|
||||
</div>
|
||||
</div>
|
||||
{:else if filteredCapsules.length === 0 && searchQuery}
|
||||
<!-- No search results -->
|
||||
<div class="flex flex-col items-center justify-center py-[72px]">
|
||||
<div class="relative mb-5">
|
||||
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-3)]">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||
No matching capsules
|
||||
</p>
|
||||
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
No capsules match "<span class="font-mono text-[var(--color-text-secondary)]">{searchQuery}</span>". Try a different ID.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => { searchQuery = ''; }}
|
||||
class="mt-4 rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredCapsules.length === 0}
|
||||
<!-- No capsules at all -->
|
||||
<div class="flex flex-col items-center justify-center py-[72px]">
|
||||
<div class="relative mb-5">
|
||||
<div class="absolute inset-0 -m-4 rounded-full" style="background: radial-gradient(circle, rgba(94,140,88,0.08) 0%, transparent 70%)"></div>
|
||||
@ -480,7 +520,13 @@
|
||||
openMenuId = null;
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
menuPos = { top: rect.bottom + 4, left: rect.right - 180 };
|
||||
const menuW = 180;
|
||||
const menuH = 140; // approximate max menu height
|
||||
const top = rect.bottom + 4 + menuH > window.innerHeight
|
||||
? rect.top - menuH - 4
|
||||
: rect.bottom + 4;
|
||||
const left = Math.max(8, Math.min(rect.right - menuW, window.innerWidth - menuW - 8));
|
||||
menuPos = { top, left };
|
||||
openMenuId = capsule.id;
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user