Refactor ListView to use table-based row rendering with useTable hook

This commit is contained in:
Jamie Pine
2025-12-01 11:48:07 -08:00
parent 142008dead
commit ec91994408
13 changed files with 661 additions and 217 deletions

View File

File diff suppressed because one or more lines are too long

View File

@@ -2023,14 +2023,6 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2038,6 +2030,14 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -2049,10 +2049,6 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2061,6 +2057,10 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -2083,14 +2083,6 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2098,6 +2090,14 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -2109,10 +2109,6 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2121,6 +2117,10 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -6290,6 +6290,34 @@
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
@@ -6305,34 +6333,6 @@
}
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
},
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}

View File

@@ -2023,14 +2023,6 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2038,6 +2030,14 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -2049,10 +2049,6 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2061,6 +2057,10 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -2083,14 +2083,6 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2098,6 +2090,14 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -2109,10 +2109,6 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -2121,6 +2117,10 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -6290,6 +6290,34 @@
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
@@ -6305,34 +6333,6 @@
}
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
},
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}

View File

@@ -190,6 +190,7 @@
"@sd/ui": "workspace:*",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.0",
@@ -1525,8 +1526,12 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
"@taplo/cli": ["@taplo/cli@0.7.0", "", { "bin": { "taplo": "dist/cli.js" } }, "sha512-Ck3zFhQhIhi02Hl6T4ZmJsXdnJE+wXcJz5f8klxd4keRYgenMnip3JDPMGDRLbnC/2iGd8P0sBIQqI3KxfVjBg=="],
@@ -5303,6 +5308,8 @@
"@sd/ui/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@sd/ui/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@sd/ui/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@sd/web/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
@@ -8775,6 +8782,10 @@
"@sd/ui/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@sd/ui/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"@sd/ui/postcss/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"@sd/web/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@sd/web/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],

1
docs/workbench Submodule

Submodule docs/workbench added at 10256fd2d6

View File

@@ -26,6 +26,7 @@
"@sd/ui": "workspace:*",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.0",

View File

@@ -1,88 +0,0 @@
import clsx from "clsx";
import type { File } from "@sd/ts-client";
import { File as FileComponent } from "../../File";
import { useExplorer } from "../../context";
import { useSelection } from "../../SelectionContext";
import { formatBytes, formatRelativeTime } from "../../utils";
import { TagPill } from "../../../Tags";
interface FileRowProps {
file: File;
fileIndex: number;
allFiles: File[];
}
export function FileRow({ file, fileIndex, allFiles }: FileRowProps) {
const { setCurrentPath } = useExplorer();
const { selectFile, isSelected } = useSelection();
const selected = isSelected(file.id);
const handleClick = (e: React.MouseEvent) => {
const multi = e.metaKey || e.ctrlKey;
const range = e.shiftKey;
selectFile(file, allFiles, multi, range);
};
const handleDoubleClick = () => {
if (file.kind === "Directory") {
setCurrentPath(file.sd_path);
}
};
return (
<div
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="flex items-center px-2 py-1.5 group"
>
<div
className={clsx(
"rounded-lg p-1.5 transition-colors mr-3",
selected ? "bg-app-box" : "bg-transparent"
)}
>
<FileComponent.Thumb file={file} size={16} />
</div>
<div className="flex-1 flex items-center gap-2 min-w-0">
<div
className={clsx(
"text-sm truncate px-2 py-0.5 rounded-md transition-colors inline-block",
selected ? "bg-accent text-white" : "text-ink"
)}
>
{file.name}
</div>
{/* Tag Pills (compact) */}
{file.tags && file.tags.length > 0 && (
<div className="flex items-center gap-1 flex-shrink-0">
{file.tags.slice(0, 2).map((tag) => (
<TagPill
key={tag.id}
color={tag.color || '#3B82F6'}
size="xs"
>
{tag.canonical_name}
</TagPill>
))}
{file.tags.length > 2 && (
<span className="text-[10px] text-ink-faint">
+{file.tags.length - 2}
</span>
)}
</div>
)}
</div>
<div className="w-24 text-sm text-ink-dull">
{file.size > 0 ? formatBytes(file.size) : "—"}
</div>
<div className="w-32 text-sm text-ink-dull">
{formatRelativeTime(file.modified_at)}
</div>
<div className="w-24 text-sm text-ink-dull">
{file.kind === "File" ? file.extension || "—" : "Folder"}
</div>
</div>
);
}

View File

@@ -1,10 +1,30 @@
import { useExplorer } from "../../context";
import { useNormalizedCache } from "../../../../context";
import { FileRow } from "./FileRow";
import type { DirectorySortBy } from "@sd/ts-client";
import { useCallback, useRef, useEffect, memo } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { flexRender } from "@tanstack/react-table";
import { CaretDown } from "@phosphor-icons/react";
import clsx from "clsx";
export function ListView() {
const { currentPath, sortBy } = useExplorer();
import type { File, DirectorySortBy } from "@sd/ts-client";
import { useExplorer } from "../../context";
import { useSelection } from "../../SelectionContext";
import { useNormalizedCache } from "../../../../context";
import { TableRow } from "./TableRow";
import {
useTable,
ROW_HEIGHT,
TABLE_PADDING_X,
TABLE_PADDING_Y,
TABLE_HEADER_HEIGHT,
} from "./useTable";
export const ListView = memo(function ListView() {
const { currentPath, sortBy, setSortBy } = useExplorer();
const { focusedIndex, setFocusedIndex, selectedFiles, selectFile, moveFocus } = useSelection();
const containerRef = useRef<HTMLDivElement>(null);
const headerScrollRef = useRef<HTMLDivElement>(null);
const bodyScrollRef = useRef<HTMLDivElement>(null);
const directoryQuery = useNormalizedCache({
wireMethod: "query:files.directory_listing",
@@ -22,25 +42,202 @@ export function ListView() {
});
const files = directoryQuery.data?.files || [];
const { table } = useTable(files);
const { rows } = table.getRowModel();
// Virtual row rendering - uses the container as scroll element
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: useCallback(() => containerRef.current, []),
estimateSize: useCallback(() => ROW_HEIGHT, []),
paddingStart: TABLE_HEADER_HEIGHT + TABLE_PADDING_Y,
paddingEnd: TABLE_PADDING_Y,
overscan: 15,
});
const virtualRows = rowVirtualizer.getVirtualItems();
// Sync horizontal scroll between header and body
const handleBodyScroll = useCallback(() => {
if (bodyScrollRef.current && headerScrollRef.current) {
headerScrollRef.current.scrollLeft = bodyScrollRef.current.scrollLeft;
}
}, []);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const direction = e.key === "ArrowDown" ? "down" : "up";
const currentIndex = focusedIndex >= 0 ? focusedIndex : 0;
const newIndex =
direction === "down"
? Math.min(currentIndex + 1, files.length - 1)
: Math.max(currentIndex - 1, 0);
if (e.shiftKey) {
// Range selection with shift
if (newIndex !== focusedIndex && files[newIndex]) {
selectFile(files[newIndex], files, false, true);
setFocusedIndex(newIndex);
}
} else {
moveFocus(direction, files);
}
// Scroll to keep selection visible
rowVirtualizer.scrollToIndex(newIndex, { align: "auto" });
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [focusedIndex, files, selectFile, setFocusedIndex, moveFocus, rowVirtualizer]);
// Column sorting handler
const handleHeaderClick = useCallback(
(columnId: string) => {
const sortMap: Record<string, DirectorySortBy> = {
name: "name",
size: "size",
modified: "modified",
type: "type",
};
const newSort = sortMap[columnId];
if (newSort) {
setSortBy(newSort);
}
},
[setSortBy]
);
// Calculate total width for table
const headerGroups = table.getHeaderGroups();
const totalWidth = table.getTotalSize() + TABLE_PADDING_X * 2;
return (
<div className="flex flex-col p-6">
<div className="flex items-center px-2 py-1 text-xs font-semibold text-ink-dull border-b border-app-line mb-2">
<div className="w-10"></div>
<div className="flex-1">Name</div>
<div className="w-24">Size</div>
<div className="w-32">Modified</div>
<div className="w-24">Type</div>
<div
ref={containerRef}
className="h-full overflow-auto"
>
{/* Sticky Header */}
<div
className="sticky top-0 z-10 border-b border-app-line bg-app/90 backdrop-blur-lg"
style={{ height: TABLE_HEADER_HEIGHT }}
>
<div
ref={headerScrollRef}
className="overflow-hidden"
>
<div
className="flex"
style={{
width: totalWidth,
paddingLeft: TABLE_PADDING_X,
paddingRight: TABLE_PADDING_X,
}}
>
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((header) => {
const isSorted = sortBy === header.id;
const canResize = header.column.getCanResize();
return (
<div
key={header.id}
className={clsx(
"relative flex select-none items-center gap-1 px-2 py-2 text-xs font-medium",
isSorted ? "text-ink" : "text-ink-dull",
"cursor-pointer hover:text-ink"
)}
style={{ width: header.getSize() }}
onClick={() => handleHeaderClick(header.id)}
>
<span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{isSorted && (
<CaretDown className="size-3 flex-shrink-0 text-ink-faint" />
)}
{/* Resize handle */}
{canResize && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => e.stopPropagation()}
className={clsx(
"absolute right-0 top-1/2 h-4 w-1 -translate-y-1/2 cursor-col-resize rounded-full",
header.column.getIsResizing()
? "bg-accent"
: "bg-transparent hover:bg-ink-faint/50"
)}
/>
)}
</div>
);
})
)}
</div>
</div>
</div>
{files.map((file, index) => (
<FileRow
key={file.id}
file={file}
fileIndex={index}
allFiles={files}
/>
))}
{/* Virtual List Body */}
<div
ref={bodyScrollRef}
className="overflow-x-auto"
onScroll={handleBodyScroll}
>
<div
className="relative"
style={{
height: rowVirtualizer.getTotalSize() - TABLE_HEADER_HEIGHT,
width: totalWidth,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${(virtualRows[0]?.start ?? 0) - TABLE_HEADER_HEIGHT - TABLE_PADDING_Y}px)`,
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
const file = row.original;
const isSelected = selectedFiles.some((f) => f.id === file.id);
const isFocused = focusedIndex === virtualRow.index;
const previousRow = rows[virtualRow.index - 1];
const nextRow = rows[virtualRow.index + 1];
const isPreviousSelected = previousRow
? selectedFiles.some((f) => f.id === previousRow.original.id)
: false;
const isNextSelected = nextRow
? selectedFiles.some((f) => f.id === nextRow.original.id)
: false;
return (
<TableRow
key={row.id}
row={row}
file={file}
files={files}
index={virtualRow.index}
isSelected={isSelected}
isFocused={isFocused}
isPreviousSelected={isPreviousSelected}
isNextSelected={isNextSelected}
measureRef={rowVirtualizer.measureElement}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -0,0 +1,176 @@
import { memo, useCallback } from "react";
import { flexRender, type Row } from "@tanstack/react-table";
import clsx from "clsx";
import type { File } from "@sd/ts-client";
import { File as FileComponent } from "../../File";
import { useExplorer } from "../../context";
import { useSelection } from "../../SelectionContext";
import { TagPill } from "../../../Tags";
import { ROW_HEIGHT, TABLE_PADDING_X } from "./useTable";
interface TableRowProps {
row: Row<File>;
file: File;
files: File[];
index: number;
isSelected: boolean;
isFocused: boolean;
isPreviousSelected: boolean;
isNextSelected: boolean;
measureRef: (node: HTMLElement | null) => void;
}
export const TableRow = memo(function TableRow({
row,
file,
files,
index,
isSelected,
isFocused,
isPreviousSelected,
isNextSelected,
measureRef,
}: TableRowProps) {
const { setCurrentPath } = useExplorer();
const { selectFile } = useSelection();
const handleClick = useCallback(
(e: React.MouseEvent) => {
const multi = e.metaKey || e.ctrlKey;
const range = e.shiftKey;
selectFile(file, files, multi, range);
},
[file, files, selectFile]
);
const handleDoubleClick = useCallback(() => {
if (file.kind === "Directory") {
setCurrentPath(file.sd_path);
}
}, [file, setCurrentPath]);
const cells = row.getVisibleCells();
return (
<div
ref={measureRef}
data-index={index}
className="relative"
style={{ height: ROW_HEIGHT }}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{/* Background layer for alternating colors and selection */}
<div
className={clsx(
"absolute inset-0 rounded-md border transition-colors",
// Alternating background
index % 2 === 0 && !isSelected && "bg-app-darkBox/50",
// Selection styling
isSelected
? "border-accent bg-accent/10"
: "border-transparent",
// Connect adjacent selected rows
isSelected && isPreviousSelected && "rounded-t-none border-t-0",
isSelected && isNextSelected && "rounded-b-none border-b-0"
)}
style={{
left: TABLE_PADDING_X,
right: TABLE_PADDING_X,
}}
>
{/* Subtle separator between connected selected rows */}
{isSelected && isPreviousSelected && (
<div className="absolute inset-x-3 top-0 h-px bg-accent/20" />
)}
</div>
{/* Row content */}
<div
className="relative flex h-full items-center"
style={{
paddingLeft: TABLE_PADDING_X,
paddingRight: TABLE_PADDING_X,
}}
>
{cells.map((cell) => {
const isNameColumn = cell.column.id === "name";
return (
<div
key={cell.id}
className={clsx(
"flex h-full items-center px-2 text-sm",
isNameColumn ? "min-w-0 flex-1" : "text-ink-dull"
)}
style={{ width: cell.column.getSize() }}
>
{isNameColumn ? (
<NameCell file={file} isSelected={isSelected} />
) : (
<span className="truncate">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</span>
)}
</div>
);
})}
</div>
</div>
);
});
// Name cell with icon and tags
const NameCell = memo(function NameCell({
file,
isSelected,
}: {
file: File;
isSelected: boolean;
}) {
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* File icon */}
<div
className={clsx(
"flex-shrink-0 rounded-md p-1 transition-colors",
isSelected ? "bg-app-box/50" : "bg-transparent"
)}
>
<FileComponent.Thumb file={file} size={20} />
</div>
{/* File name */}
<span
className={clsx(
"truncate rounded px-1.5 py-0.5 text-sm transition-colors",
isSelected ? "bg-accent text-white" : "text-ink"
)}
>
{file.name}
</span>
{/* Tags (inline, compact) */}
{file.tags && file.tags.length > 0 && (
<div className="flex flex-shrink-0 items-center gap-1">
{file.tags.slice(0, 2).map((tag) => (
<TagPill
key={tag.id}
color={tag.color || "#3B82F6"}
size="xs"
>
{tag.canonical_name}
</TagPill>
))}
{file.tags.length > 2 && (
<span className="text-[10px] text-ink-faint">
+{file.tags.length - 2}
</span>
)}
</div>
)}
</div>
);
});

View File

@@ -1,2 +1,2 @@
export { ListView } from "./ListView";
export { FileRow } from "./FileRow";
export { TableRow } from "./TableRow";

View File

@@ -0,0 +1,73 @@
import { useMemo } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type ColumnSizingState,
} from "@tanstack/react-table";
import type { File } from "@sd/ts-client";
import { formatBytes, formatRelativeTime } from "../../utils";
export const ROW_HEIGHT = 36;
export const TABLE_PADDING_X = 16;
export const TABLE_PADDING_Y = 12;
export const TABLE_HEADER_HEIGHT = 32;
// Column definitions for the list view
export function useTable(files: File[]) {
const columns = useMemo<ColumnDef<File>[]>(
() => [
{
id: "name",
header: "Name",
minSize: 200,
size: 300,
maxSize: 800,
accessorFn: (row) => row.name,
},
{
id: "size",
header: "Size",
size: 80,
minSize: 60,
maxSize: 120,
accessorFn: (row) => (row.size > 0 ? formatBytes(row.size) : "—"),
},
{
id: "modified",
header: "Modified",
size: 120,
minSize: 80,
maxSize: 180,
accessorFn: (row) => formatRelativeTime(row.modified_at),
},
{
id: "type",
header: "Type",
size: 80,
minSize: 60,
maxSize: 120,
accessorFn: (row) =>
row.kind === "File" ? row.extension?.toUpperCase() || "—" : "Folder",
},
],
[]
);
const table = useReactTable({
data: files,
columns,
defaultColumn: {
minSize: 60,
maxSize: 500,
},
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
getRowId: (row) => row.id,
});
return { table, columns };
}
export type { ColumnSizingState };

View File

@@ -7,6 +7,9 @@ import {
Folder,
HardDrive,
Tag as TagIcon,
FolderOpen,
MagnifyingGlass,
Trash,
} from "@phosphor-icons/react";
import { Location } from "@sd/assets/icons";
import type {
@@ -15,6 +18,9 @@ import type {
File,
} from "@sd/ts-client";
import { Thumb } from "../Explorer/File/Thumb";
import { useContextMenu } from "../../hooks/useContextMenu";
import { usePlatform } from "../../platform";
import { useLibraryMutation } from "../../context";
interface SpaceItemProps {
item: SpaceItemType;
@@ -107,6 +113,8 @@ export function SpaceItem({
}: SpaceItemProps) {
const navigate = useNavigate();
const location = useLocation();
const platform = usePlatform();
const deleteItem = useLibraryMutation("spaces.delete_item");
// Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type')
const isRawLocation =
@@ -151,9 +159,75 @@ export function SpaceItem({
}
};
// Context menu for space items
const contextMenu = useContextMenu({
items: [
{
icon: FolderOpen,
label: "Open",
onClick: () => {
if (path) navigate(path);
},
condition: () => !!path,
},
{
icon: MagnifyingGlass,
label: "Show in Finder",
onClick: async () => {
// For Path items, get the physical path
if (typeof item.item_type === "object" && "Path" in item.item_type) {
const sdPath = item.item_type.Path.sd_path;
if (typeof sdPath === "object" && "Physical" in sdPath) {
const physicalPath = sdPath.Physical.path;
if (platform.revealFile) {
try {
await platform.revealFile(physicalPath);
} catch (err) {
console.error("Failed to reveal file:", err);
}
}
}
}
},
keybind: "⌘⇧R",
condition: () => {
if (typeof item.item_type === "object" && "Path" in item.item_type) {
const sdPath = item.item_type.Path.sd_path;
return typeof sdPath === "object" && "Physical" in sdPath && !!platform.revealFile;
}
return false;
},
},
{ type: "separator" },
{
icon: Trash,
label: "Remove from Space",
onClick: async () => {
if (confirm(`Remove "${label}" from this space?`)) {
try {
await deleteItem.mutateAsync({ item_id: item.id });
} catch (err) {
console.error("Failed to remove item:", err);
}
}
},
variant: "danger" as const,
// Can only remove custom Path items, not built-in items
condition: () => typeof item.item_type === "object" && "Path" in item.item_type,
},
],
});
const handleContextMenu = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
await contextMenu.show(e);
};
return (
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
className ||

Submodule workbench deleted from 678ef4e295