├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .storybook ├── global.css ├── main.ts ├── preview-head.html └── preview.ts ├── LICENSE ├── README.md ├── bin ├── chat.js ├── cli.js ├── serve.js ├── streamConverters.js ├── tools.js └── types.d.ts ├── eslint.config.js ├── index.html ├── package.json ├── public └── favicon.png ├── src ├── app.tsx ├── assets │ ├── file.svg │ ├── folder.svg │ ├── global.css │ ├── logo.svg │ └── search.svg ├── components │ ├── App │ │ └── App.tsx │ ├── AvroView │ │ └── AvroView.tsx │ ├── Breadcrumb │ │ ├── Breadcrumb.module.css │ │ ├── Breadcrumb.stories.tsx │ │ ├── Breadcrumb.test.tsx │ │ └── Breadcrumb.tsx │ ├── Cell │ │ └── Cell.tsx │ ├── CellPanel │ │ └── CellPanel.tsx │ ├── Center │ │ ├── Center.module.css │ │ └── Center.tsx │ ├── ContentWrapper │ │ ├── ContentWrapper.module.css │ │ └── ContentWrapper.tsx │ ├── Dropdown │ │ ├── Dropdown.module.css │ │ ├── Dropdown.stories.tsx │ │ ├── Dropdown.test.tsx │ │ └── Dropdown.tsx │ ├── ErrorBar │ │ ├── ErrorBar.module.css │ │ └── ErrorBar.tsx │ ├── File │ │ ├── File.test.tsx │ │ └── File.tsx │ ├── Folder │ │ ├── Folder.module.css │ │ ├── Folder.test.tsx │ │ └── Folder.tsx │ ├── ImageView │ │ ├── ImageView.module.css │ │ ├── ImageView.test.tsx │ │ └── ImageView.tsx │ ├── Json │ │ ├── Json.module.css │ │ ├── Json.stories.tsx │ │ ├── Json.test.tsx │ │ ├── Json.tsx │ │ └── helpers.ts │ ├── JsonView │ │ ├── JsonView.test.tsx │ │ └── JsonView.tsx │ ├── Layout │ │ ├── Layout.module.css │ │ ├── Layout.test.tsx │ │ └── Layout.tsx │ ├── Markdown │ │ ├── Markdown.test.tsx │ │ └── Markdown.tsx │ ├── MarkdownView │ │ ├── MarkdownView.module.css │ │ ├── MarkdownView.test.tsx │ │ └── MarkdownView.tsx │ ├── Page │ │ └── Page.tsx │ ├── ParquetView │ │ ├── ParquetView.module.css │ │ └── ParquetView.tsx │ ├── ProgressBar │ │ ├── ProgressBar.module.css │ │ └── ProgressBar.tsx │ ├── SideBar │ │ ├── SideBar.module.css │ │ └── SideBar.tsx │ ├── SlideCloseButton │ │ ├── SlideCloseButton.module.css │ │ └── SlideCloseButton.tsx │ ├── SlidePanel │ │ ├── SlidePanel.module.css │ │ ├── SlidePanel.test.tsx │ │ └── SlidePanel.tsx │ ├── Spinner │ │ ├── Spinner.module.css │ │ ├── Spinner.test.tsx │ │ └── Spinner.tsx │ ├── TextView │ │ ├── TextView.module.css │ │ └── TextView.tsx │ ├── Viewer │ │ └── Viewer.tsx │ ├── VisuallyHidden │ │ ├── VisuallyHidden.module.css │ │ └── VisuallyHidden.tsx │ ├── Welcome │ │ ├── Welcome.module.css │ │ ├── Welcome.test.tsx │ │ └── Welcome.tsx │ └── index.ts ├── hooks │ └── useConfig.ts ├── index.ts ├── lib │ ├── index.ts │ ├── routes.ts │ ├── sources │ │ ├── httpSource.ts │ │ ├── hyperparamSource.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tableProvider.ts │ ├── utils.ts │ └── workers │ │ ├── parquetWorker.ts │ │ ├── parquetWorkerClient.ts │ │ └── types.ts └── vite-env.d.ts ├── test ├── bin │ ├── handleListing.test.js │ └── streamConverters.test.js ├── lib │ ├── routes.test.ts │ ├── sources │ │ ├── httpSource.test.ts │ │ └── hyperparamSource.test.ts │ └── utils.test.ts └── package.test.js ├── tsconfig.eslint.json ├── tsconfig.json ├── vite.config.ts └── vite.lib.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: ["master"] 5 | pull_request: 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - run: npm i 12 | - run: npm run lint 13 | 14 | typecheck: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: npm i 19 | - run: npm run typecheck 20 | 21 | test: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - run: npm i 26 | - run: npm run coverage 27 | 28 | buildcheck: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - run: npm i 33 | - run: npm run build 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | coverage 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | package-lock.json 28 | *.tgz 29 | .vscode 30 | *.parquet 31 | /coverage/ 32 | 33 | /lib/ 34 | tsconfig.tsbuildinfo 35 | *storybook.log 36 | -------------------------------------------------------------------------------- /.storybook/global.css: -------------------------------------------------------------------------------- 1 | /* From https://www.joshwcomeau.com/css/custom-css-reset/ */ 2 | 3 | /* 1. Use a more-intuitive box-sizing model */ 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | /* 2. Remove default margin */ 11 | * { 12 | margin: 0; 13 | } 14 | 15 | /* 3. Enable keyword animations */ 16 | @media (prefers-reduced-motion: no-preference) { 17 | html { 18 | interpolate-size: allow-keywords; 19 | } 20 | } 21 | 22 | body { 23 | /* 4. Add accessible line-height */ 24 | line-height: 1.5; 25 | /* 5. Improve text rendering */ 26 | -webkit-font-smoothing: antialiased; 27 | } 28 | 29 | /* 6. Improve media defaults */ 30 | img, 31 | picture, 32 | video, 33 | canvas, 34 | svg { 35 | display: block; 36 | max-width: 100%; 37 | } 38 | 39 | /* 7. Inherit fonts for form controls */ 40 | input, 41 | button, 42 | textarea, 43 | select { 44 | font: inherit; 45 | } 46 | 47 | /* 8. Avoid text overflows */ 48 | p, 49 | h1, 50 | h2, 51 | h3, 52 | h4, 53 | h5, 54 | h6 { 55 | overflow-wrap: break-word; 56 | } 57 | 58 | /* 9. Improve line wrapping */ 59 | p { 60 | text-wrap: pretty; 61 | } 62 | h1, 63 | h2, 64 | h3, 65 | h4, 66 | h5, 67 | h6 { 68 | text-wrap: balance; 69 | } 70 | 71 | /* 72 | 10. Create a root stacking context 73 | */ 74 | #root, 75 | #__next { 76 | isolation: isolate; 77 | } 78 | 79 | /* Storybook */ 80 | 81 | * { 82 | font-family: "Mulish", "Helvetica Neue", Helvetica, Arial, sans-serif; 83 | } 84 | 85 | .custom-versions div a { 86 | text-decoration: underline; 87 | } 88 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite' 2 | 3 | const config: StorybookConfig = { 4 | stories: [ 5 | '../src/**/*.mdx', 6 | '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', 7 | ], 8 | addons: [], 9 | framework: { 10 | name: '@storybook/react-vite', 11 | options: {}, 12 | }, 13 | core: { 14 | disableTelemetry: true, 15 | }, 16 | } 17 | export default config 18 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react-vite' 2 | import './global.css' 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | } 14 | 15 | export default preview 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hyperparam 2 | 3 | [![npm](https://img.shields.io/npm/v/hyperparam)](https://www.npmjs.com/package/hyperparam) 4 | [![workflow status](https://github.com/hyparam/hyperparam-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/hyperparam-cli/actions) 5 | [![mit license](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 6 | ![coverage](https://img.shields.io/badge/Coverage-62-darkred) 7 | 8 | This is the hyperparam cli tool. 9 | 10 | The hyperparam cli tool is for viewing arbitrarily large datasets in the browser. 11 | 12 | ## Viewer 13 | 14 | To open a file browser in your current local directory run: 15 | 16 | ```sh 17 | npx hyperparam 18 | ``` 19 | 20 | You can also pass a specific file, folder, or url: 21 | 22 | ```sh 23 | npx hyperparam example.parquet 24 | npx hyperparam directory/ 25 | npx hyperparam https://hyperparam-public.s3.amazonaws.com/bunnies.parquet 26 | ``` 27 | 28 | ## Chat 29 | 30 | To start a chat with hyperparam: 31 | 32 | ```sh 33 | npx hyperparam chat 34 | ``` 35 | 36 | ## Installation 37 | 38 | Install for all users: 39 | 40 | ```sh 41 | sudo npm i -g hyperparam 42 | ``` 43 | 44 | Now you can just run: 45 | 46 | ```sh 47 | hyperparam 48 | ``` 49 | 50 | or: 51 | 52 | ```sh 53 | hyp 54 | ``` 55 | 56 | ## Developers 57 | 58 | To develop the CLI locally: 59 | 60 | ```sh 61 | npm i 62 | npm run dev 63 | ``` 64 | 65 | The application will be rebuild automatically when you make changes, and the browser will refresh. 66 | 67 | ### Library and application 68 | 69 | Hyperparam is an application that relies on node.js scripts in the `bin/` directory and serves the static web application built in the `dist/` directory. 70 | 71 | The `hyperparam` package also includes a library that can be used in other applications. The library is in the `lib/` directory. For example: 72 | 73 | ```js 74 | import { asyncBufferFrom, AsyncBufferFrom, parquetDataFrame } from "hyperparam"; 75 | ``` 76 | -------------------------------------------------------------------------------- /bin/chat.js: -------------------------------------------------------------------------------- 1 | import { tools } from './tools.js' 2 | 3 | const systemPrompt = 4 | 'You are a machine learning web application named "hyperparam". ' + 5 | 'You assist users with building high quality ML models by introspecting on their training set data. ' + 6 | 'The website and api are available at hyperparam.app. ' + 7 | 'Hyperparam uses LLMs to analyze their own training set. ' + 8 | 'It can generate the perplexity, entropy, and other metrics of the training set. ' + 9 | 'This allows users to find segments of their data set which are difficult to model. ' + 10 | 'This could be because the data is junk, or because the data requires deeper understanding. ' + 11 | 'This is essential for closing the loop on the ML lifecycle. ' + 12 | 'The quickest way to get started is to upload a dataset and start exploring.' 13 | /** @type {Message} */ 14 | const systemMessage = { role: 'system', content: systemPrompt } 15 | 16 | const colors = { 17 | system: '\x1b[36m', // cyan 18 | user: '\x1b[33m', // yellow 19 | tool: '\x1b[90m', // gray 20 | error: '\x1b[31m', // red 21 | normal: '\x1b[0m', // reset 22 | } 23 | 24 | /** 25 | * @import { Message } from './types.d.ts' 26 | * @param {Object} chatInput 27 | * @returns {Promise} 28 | */ 29 | async function sendToServer(chatInput) { 30 | const response = await fetch('http://localhost:3000/api/functions/openai/chat', { 31 | method: 'POST', 32 | headers: { 'Content-Type': 'application/json' }, 33 | body: JSON.stringify(chatInput), 34 | }) 35 | 36 | if (!response.ok) { 37 | throw new Error(`Request failed: ${response.status}`) 38 | } 39 | 40 | // Process the streaming response 41 | const streamResponse = { role: 'assistant', content: '' } 42 | const reader = response.body.getReader() 43 | const decoder = new TextDecoder() 44 | let buffer = '' 45 | 46 | while (true) { 47 | const { done, value } = await reader.read() 48 | if (done) break 49 | buffer += decoder.decode(value, { stream: true }) 50 | const lines = buffer.split('\n') 51 | // Keep the last line in the buffer 52 | buffer = lines.pop() || '' 53 | for (const line of lines) { 54 | if (!line.trim()) continue 55 | try { 56 | const jsonChunk = JSON.parse(line) 57 | const { content, error } = jsonChunk 58 | if (content) { 59 | streamResponse.content += content 60 | write(content) 61 | } else if (error) { 62 | console.error(error) 63 | throw new Error(error) 64 | } else if (jsonChunk.function) { 65 | streamResponse.tool_calls ??= [] 66 | streamResponse.tool_calls.push(jsonChunk) 67 | } else if (!jsonChunk.key && jsonChunk.content !== '') { 68 | console.log('Unknown chunk', jsonChunk) 69 | } 70 | } catch (err) { 71 | console.error('Error parsing chunk', err) 72 | } 73 | } 74 | } 75 | return streamResponse 76 | } 77 | 78 | /** 79 | * Send messages to the server and handle tool calls. 80 | * Will mutate the messages array! 81 | * 82 | * @import { ToolCall, ToolHandler } from './types.d.ts' 83 | * @param {Message[]} messages 84 | * @returns {Promise} 85 | */ 86 | async function sendMessages(messages) { 87 | const chatInput = { 88 | messages, 89 | tools: tools.map(tool => tool.tool), 90 | } 91 | const response = await sendToServer(chatInput) 92 | messages.push(response) 93 | // handle tool results 94 | if (response.tool_calls) { 95 | /** @type {{ toolCall: ToolCall, tool: ToolHandler, result: Promise }[]} */ 96 | const toolResults = [] 97 | for (const toolCall of response.tool_calls) { 98 | const tool = tools.find(tool => tool.tool.function.name === toolCall.function.name) 99 | if (tool) { 100 | const result = tool.handleToolCall(toolCall) 101 | toolResults.push({ toolCall, tool, result }) 102 | } else { 103 | throw new Error(`Unknown tool: ${toolCall.function.name}`) 104 | } 105 | } 106 | write('\n') 107 | for (const toolResult of toolResults) { 108 | const { toolCall, tool } = toolResult 109 | const result = await toolResult.result 110 | 111 | // Construct function call message 112 | const args = JSON.parse(toolCall.function?.arguments ?? '{}') 113 | const keys = Object.keys(args) 114 | let func = toolCall.function.name 115 | if (keys.length === 0) { 116 | func += '()' 117 | } else if (keys.length === 1) { 118 | func += `(${args[keys[0]]})` 119 | } else { 120 | // transform to (arg1 = 111, arg2 = 222) 121 | const pairs = keys.map(key => `${key} = ${args[key]}`) 122 | func += `(${pairs.join(', ')})` 123 | } 124 | 125 | write(colors.tool, `${tool.emoji} ${func}`, colors.normal, '\n\n') 126 | messages.push(result) 127 | } 128 | // send messages with tool results 129 | await sendMessages(messages) 130 | } 131 | } 132 | 133 | /** 134 | * @param {string[]} args 135 | */ 136 | function write(...args) { 137 | args.forEach(s => process.stdout.write(s)) 138 | } 139 | 140 | export function chat() { 141 | /** @type {Message[]} */ 142 | const messages = [systemMessage] 143 | process.stdin.setEncoding('utf-8') 144 | 145 | write(colors.system, 'question: ', colors.normal) 146 | 147 | process.stdin.on('data', async (/** @type {string} */ input) => { 148 | input = input.trim() 149 | if (input === 'exit') { 150 | process.exit() 151 | } else if (input) { 152 | try { 153 | write(colors.user, 'answer: ', colors.normal) 154 | messages.push({ role: 'user', content: input.trim() }) 155 | await sendMessages(messages) 156 | } catch (error) { 157 | console.error(colors.error, '\n' + error) 158 | } finally { 159 | write('\n\n') 160 | } 161 | } 162 | write(colors.system, 'question: ', colors.normal) 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs/promises' 4 | import packageJson from '../package.json' with { type: 'json' } 5 | import { chat } from './chat.js' 6 | import { serve } from './serve.js' 7 | 8 | const updateCheck = checkForUpdates() 9 | 10 | const arg = process.argv[2] 11 | if (arg === 'chat') { 12 | await updateCheck // wait for update check to finish before chat 13 | chat() 14 | } else if (arg === '--help' || arg === '-H' || arg === '-h') { 15 | console.log('Usage:') 16 | console.log(' hyperparam [path] start hyperparam webapp. "path" is a directory or a URL.') 17 | console.log(' defaults to the current directory.') 18 | console.log(' hyperparam chat start chat client') 19 | console.log(' ') 20 | console.log(' hyperparam -h, --help, give this help list') 21 | console.log(' hyperparam -v, --version print program version') 22 | } else if (arg === '--version' || arg === '-V' || arg === '-v') { 23 | console.log(packageJson.version) 24 | } else if (!arg) { 25 | serve(process.cwd(), undefined) // current directory 26 | } else if (/^https?:\/\//.exec(arg)) { 27 | serve(undefined, arg) // url 28 | } else { 29 | // resolve file or directory 30 | fs.stat(arg).then(async stat => { 31 | const path = await fs.realpath(arg) 32 | if (stat.isDirectory()) { 33 | serve(path, undefined) 34 | } else if (stat.isFile()) { 35 | const parent = path.split('/').slice(0, -1).join('/') 36 | const key = path.split('/').pop() 37 | serve(parent, key) 38 | } 39 | }).catch(() => { 40 | console.error(`Error: file ${process.argv[2]} does not exist`) 41 | process.exit(1) 42 | }) 43 | } 44 | 45 | /** 46 | * Check for updates and notify user if a newer version is available. 47 | * Runs in the background. 48 | * @returns {Promise} 49 | */ 50 | function checkForUpdates() { 51 | const currentVersion = packageJson.version 52 | return fetch('https://registry.npmjs.org/hyperparam/latest') 53 | .then(response => response.json()) 54 | .then(data => { 55 | const latestVersion = data.version 56 | if (latestVersion && latestVersion !== currentVersion) { 57 | console.log(`\x1b[33mA newer version of hyperparam is available: ${latestVersion} (current: ${currentVersion})\x1b[0m`) 58 | console.log('\x1b[33mRun \'npm install -g hyperparam\' to update\x1b[0m') 59 | } 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /bin/serve.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Dependency-free http server for serving static files 3 | */ 4 | 5 | import { exec } from 'child_process' 6 | import { createReadStream } from 'fs' 7 | import fs from 'fs/promises' 8 | import http from 'http' 9 | import path from 'path' 10 | import url from 'url' 11 | import zlib from 'zlib' 12 | import { pipe, readStreamToReadableStream } from './streamConverters.js' 13 | 14 | /** @type {Object} */ 15 | const mimeTypes = { 16 | '.html': 'text/html', 17 | '.js': 'application/javascript', 18 | '.css': 'text/css', 19 | '.csv': 'text/csv', 20 | '.json': 'application/json', 21 | '.map': 'application/json', 22 | '.md': 'text/markdown', 23 | '.ico': 'image/x-icon', 24 | '.jpeg': 'image/jpeg', 25 | '.jpg': 'image/jpeg', 26 | '.parquet': 'application/x-parquet', 27 | '.pdf': 'application/pdf', 28 | '.png': 'image/png', 29 | '.svg': 'image/svg+xml', 30 | '.txt': 'text/plain', 31 | '.ttf': 'font/ttf', 32 | '.woff2': 'font/woff2', 33 | } 34 | 35 | /** 36 | * @template T 37 | * @typedef {T | Promise} Awaitable 38 | */ 39 | 40 | /** 41 | * Start http server with optional path 42 | * @param {string | undefined} serveDirectory serve files from this directory 43 | * @param {string | undefined} key file to serve by default 44 | */ 45 | export async function serve(serveDirectory, key) { 46 | // Search for open port 47 | let port = 2048 48 | while (port < 2048 + 10) { 49 | try { 50 | await startServer(port, serveDirectory) 51 | break 52 | } catch (/** @type {any} */ err) { 53 | if (err.code !== 'EADDRINUSE') throw err 54 | console.error(`port ${port} in use, trying next port`) 55 | if (port === 2048) port++ // skip unsafe nfs port 2049 56 | port++ 57 | } 58 | } 59 | const path = key ? `/files?key=${encodeURIComponent(key)}` : '/' 60 | const url = `http://localhost:${port}${path}` 61 | console.log(`hyperparam server running on ${url}`) 62 | if (process.env.NODE_ENV !== 'development') { 63 | openUrl(url) 64 | } 65 | } 66 | 67 | /** 68 | * Route an http request 69 | * @typedef {Object} ReadableStream 70 | * @typedef {{ status: number, content: string | Buffer | ReadableStream, contentLength?: number, contentType?: string }} ServeResult 71 | * @param {http.IncomingMessage} req 72 | * @param {string | undefined} serveDirectory 73 | * @returns {Awaitable} 74 | */ 75 | function handleRequest(req, serveDirectory) { 76 | if (!req.url) return { status: 400, content: 'bad request' } 77 | const parsedUrl = url.parse(req.url, true) 78 | const pathname = parsedUrl.pathname || '' 79 | 80 | // get location of hyperparam assets 81 | const hyperparamPath = decodeURIComponent(import.meta.url) 82 | .replace('file://', '') 83 | .replace('/bin/serve.js', '') 84 | 85 | if (pathname === '/' || pathname === '/files/') { 86 | // redirect to /files 87 | return { status: 301, content: '/files' } 88 | } else if (pathname.startsWith('/files')) { 89 | // serve index.html 90 | return handleStatic(`${hyperparamPath}/dist/index.html`) 91 | } else if (pathname.startsWith('/assets/') || pathname.startsWith('/favicon') ) { 92 | // serve static files 93 | return handleStatic(`${hyperparamPath}/dist${pathname}`) 94 | } else if (serveDirectory && pathname === '/api/store/list') { 95 | // serve file list 96 | const prefix = parsedUrl.query.prefix || '' 97 | if (Array.isArray(prefix)) return { status: 400, content: 'bad request' } 98 | const prefixPath = `${serveDirectory}/${decodeURIComponent(prefix)}` 99 | return handleListing(prefixPath) 100 | } else if (serveDirectory && pathname === '/api/store/get') { 101 | // serve file content 102 | const key = parsedUrl.query.key || '' 103 | if (Array.isArray(key)) return { status: 400, content: 'bad request' } 104 | const filePath = `${serveDirectory}/${decodeURIComponent(key)}` 105 | if (req.method === 'HEAD') { 106 | return handleHead(filePath) 107 | } 108 | const range = req.method === 'HEAD' ? '0-0' : req.headers.range 109 | return handleStatic(filePath, range) 110 | } else { 111 | return { status: 404, content: 'not found' } 112 | } 113 | } 114 | 115 | /** 116 | * Serve static file from the serve directory 117 | * @param {string} filePath 118 | * @param {string} [range] 119 | * @returns {Promise} 120 | */ 121 | async function handleStatic(filePath, range) { 122 | const stats = await fs.stat(filePath).catch(() => undefined) 123 | if (!stats?.isFile()) { 124 | return { status: 404, content: 'not found' } 125 | } 126 | const contentLength = stats.size 127 | 128 | // detect content type 129 | const extname = path.extname(filePath) 130 | if (!mimeTypes[extname]) console.error(`serving unknown mimetype ${extname}`) 131 | const contentType = mimeTypes[extname] || 'application/octet-stream' 132 | 133 | // ranged requests 134 | if (range) { 135 | const [unit, ranges] = range.split('=') 136 | if (unit === 'bytes') { 137 | const [start, end] = ranges.split('-').map(Number) 138 | 139 | // convert fs.ReadStream to web stream 140 | const fsStream = createReadStream(filePath, { start, end }) 141 | const content = readStreamToReadableStream(fsStream) 142 | const contentLength = end - start + 1 143 | 144 | return { 145 | status: 206, 146 | content, 147 | contentLength, 148 | contentType, 149 | } 150 | } 151 | } 152 | 153 | const content = await fs.readFile(filePath) 154 | return { status: 200, content, contentLength, contentType } 155 | } 156 | 157 | /** 158 | * Serve head request 159 | * @param {string} filePath 160 | * @returns {Promise} 161 | */ 162 | async function handleHead(filePath) { 163 | const stats = await fs.stat(filePath).catch(() => undefined) 164 | if (!stats?.isFile()) { 165 | console.error(`file not found ${filePath}`) 166 | return { status: 404, content: 'not found' } 167 | } 168 | const contentLength = stats.size 169 | 170 | // detect content type 171 | const extname = path.extname(filePath) 172 | if (!mimeTypes[extname]) console.error(`serving unknown mimetype ${extname}`) 173 | const contentType = mimeTypes[extname] || 'application/octet-stream' 174 | 175 | return { status: 200, content: '', contentLength, contentType } 176 | } 177 | 178 | /** 179 | * List files from local storage 180 | * 181 | * @param {string} prefix file path prefix 182 | * @returns {Promise} 183 | */ 184 | export async function handleListing(prefix) { 185 | try { 186 | const stat = await fs.stat(prefix) 187 | if (!stat.isDirectory()) return { status: 400, content: 'not a directory' } 188 | } catch { 189 | return { status: 404, content: 'not found' } 190 | } 191 | 192 | const files = [] 193 | for (const filename of await fs.readdir(prefix, { recursive: false })) { 194 | // get stats for each file 195 | const filePath = `${prefix}/${filename}` 196 | const stat = await fs.stat(filePath) 197 | .catch(() => undefined) // handle bad symlinks 198 | 199 | if (stat?.isFile()) { 200 | files.push({ 201 | key: filename, 202 | fileSize: stat.size, 203 | lastModified: stat.mtime.toISOString(), 204 | }) 205 | } else if (stat?.isDirectory()) { 206 | files.push({ 207 | key: filename + '/', 208 | lastModified: stat.mtime.toISOString(), 209 | }) 210 | } 211 | } 212 | files.sort((a, b) => { 213 | const isDirA = a.key.endsWith('/') 214 | const isDirB = b.key.endsWith('/') 215 | 216 | // Prioritize directories over files 217 | if (isDirA && !isDirB) return -1 218 | if (!isDirA && isDirB) return 1 219 | 220 | // Check for dot folders/files and special char folders/files 221 | const isDotFolderA = a.key.startsWith('.') 222 | const isDotFolderB = b.key.startsWith('.') 223 | const hasSpecialCharsA = /[^a-zA-Z0-9]/.test(a.key) 224 | const hasSpecialCharsB = /[^a-zA-Z0-9]/.test(b.key) 225 | 226 | // Handle dot folders/files first 227 | if (isDotFolderA && !isDotFolderB) return -1 228 | if (!isDotFolderA && isDotFolderB) return 1 229 | 230 | // Handle special character folders/files second 231 | if (hasSpecialCharsA && !hasSpecialCharsB) return 1 232 | if (!hasSpecialCharsA && hasSpecialCharsB) return -1 233 | 234 | return a.key.localeCompare(b.key) 235 | }) 236 | 237 | return { status: 200, content: JSON.stringify(files), contentType: 'application/json' } 238 | } 239 | 240 | /** 241 | * @param {number} port 242 | * @param {string | undefined} path serve files from this directory 243 | * @returns {Promise} 244 | */ 245 | function startServer(port, path) { 246 | return new Promise((resolve, reject) => { 247 | // create http server 248 | const server = http.createServer(async (req, res) => { 249 | const startTime = new Date() 250 | 251 | // handle request 252 | /** @type {ServeResult} */ 253 | let result = { status: 500, content: 'internal server error' } 254 | try { 255 | result = await handleRequest(req, path) 256 | } catch (err) { 257 | console.error('error handling request', err) 258 | } 259 | const { status } = result 260 | let { content } = result 261 | 262 | // write http header 263 | /** @type {http.OutgoingHttpHeaders} */ 264 | const headers = { 'Connection': 'keep-alive' } 265 | if (result.contentLength !== undefined) { 266 | headers['Content-Length'] = result.contentLength 267 | } 268 | if (result.contentType) headers['Content-Type'] = result.contentType 269 | if (status === 301 && typeof content === 'string') { 270 | // handle redirect 271 | headers.Location = content 272 | content = '' 273 | } 274 | // compress content 275 | const gzipped = gzip(req, content) 276 | if (gzipped) { 277 | headers['Content-Encoding'] = 'gzip' 278 | content = gzipped 279 | } 280 | res.writeHead(status, headers) 281 | 282 | // write http response 283 | if (content instanceof Buffer || typeof content === 'string') { 284 | res.end(content) 285 | } else if (content instanceof ReadableStream) { 286 | pipe(content, res) 287 | } 288 | 289 | // log request 290 | const endTime = new Date() 291 | const ms = endTime.getTime() - startTime.getTime() 292 | // @ts-expect-error contentLength will exist if content is ReadableStream 293 | const length = result.contentLength || content.length || 0 294 | const line = `${endTime.toISOString()} ${status} ${req.method} ${req.url} ${length} ${ms}ms` 295 | if (status < 400) { 296 | console.log(line) 297 | } else { 298 | // highlight errors red 299 | console.log(`\x1b[31m${line}\x1b[0m`) 300 | } 301 | }) 302 | server.on('error', reject) 303 | server.listen(port, resolve) 304 | }) 305 | } 306 | 307 | /** 308 | * If the request accepts gzip, compress the content, else undefined 309 | * @param {http.IncomingMessage} req 310 | * @param {string | Buffer | ReadableStream} content 311 | * @returns {Buffer | undefined} 312 | */ 313 | function gzip(req, content) { 314 | if (!(content instanceof Buffer) || !(typeof content === 'string')) return undefined 315 | const acceptEncoding = req.headers['accept-encoding'] 316 | if (acceptEncoding?.includes('gzip')) { 317 | return zlib.gzipSync(content) 318 | } 319 | } 320 | 321 | /** 322 | * @param {string} url 323 | * @returns {void} 324 | */ 325 | function openUrl(url) { 326 | switch (process.platform) { 327 | case 'darwin': exec(`open ${url}`); return 328 | case 'win32': exec(`start ${url}`); return 329 | case 'linux': exec(`xdg-open ${url}`); return 330 | default: throw new Error(`unsupported platform ${process.platform}`) 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /bin/streamConverters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pipe a web ReadableStream to a node Writable. 3 | * @typedef {import('stream').Writable} Writable 4 | * @param {ReadableStream} input 5 | * @param {Writable} output 6 | * @returns {Promise} 7 | */ 8 | export async function pipe(input, output) { 9 | // TODO: typescript hates for-await? should just be: 10 | // for await (const chunk of input) {} 11 | const reader = input.getReader() 12 | while (true) { 13 | const { done, value } = await reader.read() 14 | if (done) break 15 | output.write(value) 16 | } 17 | output.end() 18 | } 19 | 20 | /** 21 | * Convert a node fs ReadStream to a web ReadableStream. 22 | * @typedef {import('fs').ReadStream} ReadStream 23 | * @param {ReadStream} fsStream 24 | * @returns {ReadableStream} 25 | */ 26 | export function readStreamToReadableStream(fsStream) { 27 | return new ReadableStream({ 28 | start(/** @type {ReadableStreamDefaultController} */ controller) { 29 | fsStream.on('data', (chunk) => { controller.enqueue(chunk) }) 30 | fsStream.on('end', () => { controller.close() }) 31 | fsStream.on('error', (error) => { controller.error(error) }) 32 | }, 33 | cancel() { 34 | fsStream.destroy() 35 | }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /bin/tools.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import { asyncBufferFromFile, parquetQuery, toJson } from 'hyparquet' 3 | 4 | /** 5 | * @import { Message, ToolCall, ToolHandler } from './types.d.ts' 6 | * @type {ToolHandler[]} 7 | */ 8 | export const tools = [ 9 | { 10 | emoji: '📂', 11 | tool: { 12 | type: 'function', 13 | function: { 14 | name: 'list_files', 15 | description: 'List the files in the current directory.', 16 | parameters: { 17 | type: 'object', 18 | properties: { 19 | path: { type: 'string', description: 'The path to list files from (optional).' }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | /** 25 | * @param {ToolCall} toolCall 26 | * @returns {Promise} 27 | */ 28 | async handleToolCall(toolCall) { 29 | let { path = '.' } = JSON.parse(toolCall.function.arguments || '{}') 30 | if (path.includes('..') || path.includes('~')) { 31 | throw new Error('Invalid path: ' + path) 32 | } 33 | // append to current directory 34 | path = process.cwd() + '/' + path 35 | // list files in the current directory 36 | const filenames = await fs.readdir(path) 37 | return { role: 'tool', content: `Files:\n${filenames.join('\n')}`, tool_call_id: toolCall.id } 38 | }, 39 | }, 40 | { 41 | emoji: '📄', 42 | tool: { 43 | type: 'function', 44 | function: { 45 | name: 'read_parquet', 46 | description: 'Read rows from a parquet file. Do not request more than 5 rows.', 47 | parameters: { 48 | type: 'object', 49 | properties: { 50 | filename: { type: 'string', description: 'The name of the parquet file to read.' }, 51 | rowStart: { type: 'integer', description: 'The start row index.' }, 52 | rowEnd: { type: 'integer', description: 'The end row index.' }, 53 | orderBy: { type: 'string', description: 'The column name to sort by.' }, 54 | }, 55 | required: ['filename'], 56 | }, 57 | }, 58 | }, 59 | /** 60 | * @param {ToolCall} toolCall 61 | * @returns {Promise} 62 | */ 63 | async handleToolCall(toolCall) { 64 | const { filename, rowStart = 0, rowEnd = 5, orderBy } = JSON.parse(toolCall.function.arguments || '{}') 65 | if (rowEnd - rowStart > 5) { 66 | throw new Error('Do NOT request more than 5 rows.') 67 | } 68 | const file = await asyncBufferFromFile(filename) 69 | const rows = await parquetQuery({ file, rowStart, rowEnd, orderBy }) 70 | let content = '' 71 | for (let i = rowStart; i < rowEnd; i++) { 72 | content += `Row ${i}: ${JSON.stringify(toJson(rows[i]))}\n` 73 | } 74 | return { role: 'tool', content, tool_call_id: toolCall.id } 75 | }, 76 | }, 77 | ] 78 | -------------------------------------------------------------------------------- /bin/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface ToolCall { 2 | id: string 3 | type: 'function' 4 | function: { 5 | name: string 6 | arguments?: string 7 | } 8 | } 9 | 10 | export type Role = 'system' | 'user' | 'assistant' | 'tool' 11 | 12 | export interface Message { 13 | role: Role 14 | content: string 15 | tool_calls?: ToolCall[] 16 | tool_call_id?: string 17 | error?: string 18 | } 19 | 20 | export interface ToolHandler { 21 | emoji: string 22 | tool: Tool 23 | handleToolCall(toolCall: ToolCall): Promise 24 | } 25 | interface ToolProperty { 26 | type: string 27 | description: string 28 | } 29 | 30 | export interface Tool { 31 | type: 'function' 32 | function: { 33 | name: string 34 | description: string 35 | parameters?: { 36 | type: 'object' 37 | properties: Record 38 | required?: string[] 39 | additionalProperties?: boolean 40 | }, 41 | strict?: boolean 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import javascript from '@eslint/js' 2 | import react from 'eslint-plugin-react' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import storybook from 'eslint-plugin-storybook' 6 | import globals from 'globals' 7 | import typescript from 'typescript-eslint' 8 | 9 | export default typescript.config( 10 | { ignores: ['coverage/', 'dist/', 'lib/'] }, 11 | { 12 | extends: [javascript.configs.recommended, ...typescript.configs.strictTypeChecked, ...typescript.configs.stylisticTypeChecked], 13 | files: ['**/*.{ts,tsx,js}'], 14 | languageOptions: { 15 | globals: globals.browser, 16 | parserOptions: { 17 | project: ['./tsconfig.json', './tsconfig.eslint.json'], 18 | tsconfigRootDir: import.meta.dirname, 19 | }, 20 | }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...react.configs.recommended.rules, 28 | ...react.configs['jsx-runtime'].rules, 29 | ...reactHooks.configs['recommended-latest'].rules, 30 | 'react-refresh/only-export-components': [ 31 | 'warn', 32 | { allowConstantExport: true }, 33 | ], 34 | ...javascript.configs.recommended.rules, 35 | ...typescript.configs.recommended.rules, 36 | // javascript 37 | 'arrow-spacing': 'error', 38 | camelcase: 'off', 39 | 'comma-spacing': 'error', 40 | 'comma-dangle': ['error', { 41 | arrays: 'always-multiline', 42 | objects: 'always-multiline', 43 | imports: 'always-multiline', 44 | exports: 'always-multiline', 45 | functions: 'never', 46 | }], 47 | 'eol-last': 'error', 48 | eqeqeq: 'error', 49 | 'func-style': ['error', 'declaration'], 50 | indent: ['error', 2, { SwitchCase: 1 }], 51 | 'key-spacing': 'error', 52 | 'no-constant-condition': 'off', 53 | 'no-extra-parens': 'error', 54 | 'no-multi-spaces': 'error', 55 | 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }], 56 | 'no-trailing-spaces': 'error', 57 | 'no-undef': 'error', 58 | 'no-unused-vars': 'off', 59 | 'no-useless-concat': 'error', 60 | 'no-useless-rename': 'error', 61 | 'no-useless-return': 'error', 62 | 'no-var': 'error', 63 | 'object-curly-spacing': ['error', 'always'], 64 | 'object-shorthand': 'error', 65 | 'prefer-const': 'warn', 66 | 'prefer-destructuring': ['warn', { 67 | object: true, 68 | array: false, 69 | }], 70 | 'prefer-promise-reject-errors': 'error', 71 | quotes: ['error', 'single'], 72 | 'require-await': 'warn', 73 | semi: ['error', 'never'], 74 | 'sort-imports': ['error', { 75 | ignoreDeclarationSort: true, 76 | ignoreMemberSort: false, 77 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 78 | }], 79 | 'space-infix-ops': 'error', 80 | // typescript 81 | '@typescript-eslint/restrict-template-expressions': 'off', 82 | '@typescript-eslint/no-unused-vars': 'warn', 83 | '@typescript-eslint/require-await': 'warn', 84 | // react hooks 85 | 'react-hooks/exhaustive-deps': 'error', 86 | }, 87 | settings: { react: { version: 'detect' } }, 88 | }, 89 | { 90 | files: ['test/**/*.{ts,tsx}', '*.{js,ts}'], 91 | languageOptions: { 92 | ecmaVersion: 2020, 93 | globals: { 94 | ...globals.node, 95 | ...globals.browser, 96 | }, 97 | }, 98 | }, 99 | { 100 | files: ['**/*.js'], 101 | ...typescript.configs.disableTypeChecked, 102 | }, 103 | { 104 | files: ['bin/**/*.js', 'test/bin/**/*.js'], 105 | languageOptions: { 106 | globals: { 107 | ...globals.node, 108 | }, 109 | }, 110 | }, 111 | { 112 | extends: [ 113 | ...storybook.configs['flat/recommended'], 114 | ], 115 | files: ['**/*.stories.tsx'], 116 | } 117 | ) 118 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | hyperparam 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperparam", 3 | "version": "0.2.49", 4 | "description": "Hyperparam CLI", 5 | "author": "Hyperparam", 6 | "homepage": "https://hyperparam.app", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/hyparam/hyperparam-cli.git" 11 | }, 12 | "type": "module", 13 | "exports": { 14 | ".": { 15 | "types": "./lib/index.d.ts", 16 | "import": "./lib/index.es.min.js", 17 | "require": "./lib/index.umd.min.js" 18 | }, 19 | "./global.css": "./lib/global.css", 20 | "./hyperparam.css": "./lib/hyperparam.css" 21 | }, 22 | "bin": { 23 | "hyp": "bin/cli.js", 24 | "hyperparam": "bin/cli.js" 25 | }, 26 | "files": [ 27 | "bin", 28 | "dist", 29 | "lib" 30 | ], 31 | "scripts": { 32 | "build:types": "tsc -b", 33 | "build:lib": "vite build -c vite.lib.config.ts && cp src/assets/global.css lib/global.css", 34 | "build:app": "vite build", 35 | "build": "run-s build:lib build:types build:app", 36 | "coverage": "vitest run --coverage --coverage.include=src --coverage.include=bin", 37 | "dev:cli": "run-p -l watch:ts watch:cli watch:serve", 38 | "dev": "run-p -l watch:ts watch:static", 39 | "lint": "eslint", 40 | "lint:fix": "eslint --fix", 41 | "prepublishOnly": "npm run build", 42 | "serve": "node bin/cli.js", 43 | "preserve": "npm run build", 44 | "storybook": "storybook dev -p 6006", 45 | "test": "vitest run", 46 | "typecheck": "tsc --noEmit", 47 | "url": "run-p -l watch:ts watch:vite watch:url", 48 | "preurl": "npm run build", 49 | "watch:cli": "vite build --watch", 50 | "watch:serve": "NODE_ENV=development nodemon bin/cli.js", 51 | "watch:static": "vite", 52 | "watch:ts": "tsc --watch", 53 | "watch:url": "NODE_ENV=development nodemon bin/cli.js https://hyperparam.blob.core.windows.net/hyperparam/starcoderdata-js-00000-of-00065.parquet" 54 | }, 55 | "dependencies": { 56 | "hightable": "0.17.0", 57 | "hyparquet": "1.15.0", 58 | "hyparquet-compressors": "1.1.1", 59 | "icebird": "0.3.0", 60 | "react": "18.3.1", 61 | "react-dom": "18.3.1" 62 | }, 63 | "devDependencies": { 64 | "@eslint/js": "9.28.0", 65 | "@storybook/react-vite": "9.0.4", 66 | "@testing-library/react": "16.3.0", 67 | "@types/node": "22.15.29", 68 | "@types/react": "19.1.6", 69 | "@types/react-dom": "19.1.6", 70 | "@vitejs/plugin-react": "4.5.1", 71 | "@vitest/coverage-v8": "3.2.1", 72 | "eslint": "9.28.0", 73 | "eslint-plugin-react": "7.37.5", 74 | "eslint-plugin-react-hooks": "5.2.0", 75 | "eslint-plugin-react-refresh": "0.4.20", 76 | "eslint-plugin-storybook": "9.0.4", 77 | "globals": "16.2.0", 78 | "jsdom": "26.1.0", 79 | "nodemon": "3.1.10", 80 | "npm-run-all": "4.1.5", 81 | "storybook": "9.0.4", 82 | "typescript": "5.8.3", 83 | "typescript-eslint": "8.33.1", 84 | "vite": "6.3.5", 85 | "vitest": "3.2.1" 86 | }, 87 | "eslintConfig": { 88 | "extends": [ 89 | "plugin:storybook/recommended" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyparam/hyperparam-cli/ebf829a6902bc83bdc4c5fa4321059be53c4126e/public/favicon.png -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './assets/global.css' 4 | import App from './components/App/App.js' 5 | 6 | const root = document.getElementById('app') 7 | if (!root) throw new Error('missing root element') 8 | createRoot(root).render( 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /src/assets/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | font-family: 'Mulish', 'Helvetica Neue', Helvetica, Arial, sans-serif; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | button { 9 | background-color: #111; 10 | color: #fff; 11 | border: none; 12 | border-radius: 8px; 13 | padding: 8px 16px; 14 | cursor: pointer; 15 | outline: none; 16 | transition: background-color 0.2s; 17 | } 18 | button:active, 19 | button:focus, 20 | button:hover { 21 | background-color: #333; 22 | } 23 | 24 | /* inline code block */ 25 | code { 26 | font-family: monospace; 27 | } 28 | 29 | h1 { 30 | font-size: 32px; 31 | font-weight: 500; 32 | margin-bottom: 8px; 33 | } 34 | h2 { 35 | font-weight: 500; 36 | margin-bottom: 8px; 37 | margin-top: 16px; 38 | } 39 | h3 { 40 | margin-bottom: 8px; 41 | margin-top: 16px; 42 | } 43 | 44 | ol, 45 | ul { 46 | margin: 10px 0; 47 | padding: 0 0 0 20px; 48 | } 49 | li { 50 | margin: 4px 0; 51 | } 52 | 53 | #app { 54 | display: flex; 55 | flex-direction: column; 56 | height: 100vh; 57 | } 58 | 59 | a { 60 | color: #342267; 61 | cursor: pointer; 62 | text-decoration: none; 63 | } 64 | a:hover { 65 | color: #000; 66 | text-decoration: underline; 67 | } 68 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { Config, ConfigProvider } from '../../hooks/useConfig.js' 3 | import { getHttpSource } from '../../lib/sources/httpSource.js' 4 | import { getHyperparamSource } from '../../lib/sources/hyperparamSource.js' 5 | import Page from '../Page/Page.js' 6 | 7 | export default function App() { 8 | const search = new URLSearchParams(location.search) 9 | const sourceId = search.get('key') ?? '' 10 | const row = search.get('row') === null ? undefined : Number(search.get('row')) 11 | const col = search.get('col') === null ? undefined : Number(search.get('col')) 12 | 13 | const source = getHttpSource(sourceId) ?? getHyperparamSource(sourceId, { endpoint: location.origin }) 14 | 15 | // Memoize the config to avoid creating a new object on each render 16 | const config: Config = useMemo(() => ({ 17 | routes: { 18 | getSourceRouteUrl: ({ sourceId }) => `/files?key=${sourceId}`, 19 | getCellRouteUrl: ({ sourceId, col, row }) => `/files?key=${sourceId}&col=${col}&row=${row}`, 20 | }, 21 | }), []) 22 | 23 | if (!source) { 24 | return
Could not load a data source. You have to pass a valid source in the url.
25 | } 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/AvroView/AvroView.tsx: -------------------------------------------------------------------------------- 1 | import { avroMetadata, avroRead } from 'icebird' 2 | import { useEffect, useState } from 'react' 3 | import type { FileSource } from '../../lib/sources/types.js' 4 | import { parseFileSize } from '../../lib/utils.js' 5 | import ContentWrapper, { ContentSize } from '../ContentWrapper/ContentWrapper.js' 6 | import Json from '../Json/Json.js' 7 | import styles from '../Json/Json.module.css' 8 | 9 | interface ViewerProps { 10 | source: FileSource 11 | setError: (error: Error | undefined) => void 12 | } 13 | 14 | /** 15 | * Apache Avro viewer component. 16 | */ 17 | export default function AvroView({ source, setError }: ViewerProps) { 18 | const [content, setContent] = useState() 19 | const [json, setJson] = useState() 20 | const [isLoading, setIsLoading] = useState(true) 21 | 22 | const { resolveUrl, requestInit } = source 23 | 24 | // Load avro content as json 25 | useEffect(() => { 26 | async function loadContent() { 27 | try { 28 | setIsLoading(true) 29 | const res = await fetch(resolveUrl, requestInit) 30 | if (res.status === 401) { 31 | const text = await res.text() 32 | setError(new Error(text)) 33 | setContent(undefined) 34 | return 35 | } 36 | // Parse avro file 37 | const buffer = await res.arrayBuffer() 38 | const fileSize = parseFileSize(res.headers) ?? buffer.byteLength 39 | const reader = { view: new DataView(buffer), offset: 0 } 40 | const { metadata, syncMarker } = avroMetadata(reader) 41 | const json = avroRead({ reader, metadata, syncMarker }) 42 | setError(undefined) 43 | setContent({ fileSize }) 44 | setJson(json) 45 | } catch (error) { 46 | setError(error as Error) 47 | } finally { 48 | setIsLoading(false) 49 | } 50 | } 51 | void loadContent() 52 | }, [resolveUrl, requestInit, setError]) 53 | 54 | const headers = content === undefined && Loading... 55 | 56 | return 57 | 58 | 59 | 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/Breadcrumb.module.css: -------------------------------------------------------------------------------- 1 | .breadcrumb { 2 | /* top navbar */ 3 | align-items: center; 4 | display: flex; 5 | font-size: 18px; 6 | height: 32px; 7 | justify-content: space-between; 8 | gap: 10px; 9 | min-height: 32px; 10 | padding-left: 20px; 11 | padding-right: 10px; 12 | border-bottom: 1px solid #ddd; 13 | background: #eee; 14 | /* TODO(SL): forbid overflow? */ 15 | 16 | h1 { 17 | font-size: 18px; 18 | margin: 4px 0 0 0; /* top */ 19 | user-select: none; 20 | } 21 | } 22 | 23 | /* file path */ 24 | .path { 25 | margin: 0 2px; 26 | margin-right: auto; 27 | min-width: 0; 28 | overflow: auto; 29 | /* TODO(SL): forbid wrap + use an ellipsis instead? */ 30 | 31 | &::-webkit-scrollbar { 32 | display: none; 33 | } 34 | a { 35 | color: #222622; 36 | font-family: "Courier New", Courier, monospace; 37 | font-weight: 600; 38 | font-size: 18px; 39 | text-overflow: ellipsis; 40 | white-space: nowrap; 41 | text-decoration-thickness: 1px; 42 | } 43 | /* hide all but the last path link on small screens */ 44 | @media (max-width: 360px) { 45 | & a:not(:last-child) { 46 | display: none; 47 | } 48 | } 49 | } 50 | 51 | .versions { 52 | padding-left: 4px; 53 | 54 | [aria-current] { 55 | font-weight: bold; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/Breadcrumb.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import { ConfigProvider } from '../../hooks/useConfig.js' 3 | import Breadcrumb from './Breadcrumb.js' 4 | 5 | const meta: Meta = { 6 | component: Breadcrumb, 7 | } 8 | export default meta 9 | type Story = StoryObj; 10 | export const Default: Story = { 11 | args: { 12 | source: { 13 | kind: 'file', 14 | sourceId: '/part1/part2/file.txt', 15 | fileName: 'file.txt', 16 | resolveUrl: '/part1/part2/file.txt', 17 | sourceParts: [ 18 | { text: '/', sourceId: '/' }, 19 | { text: 'part1/', sourceId: '/part1/' }, 20 | { text: 'part2/', sourceId: '/part1/part2/' }, 21 | ], 22 | fetchVersions: () => { 23 | return Promise.resolve({ 24 | label: 'Branches', 25 | versions: [ 26 | { label: 'master', sourceId: '/part1/part2/file.txt' }, 27 | { label: 'dev', sourceId: '/part1/part2/file.txt?branch=dev' }, 28 | { label: 'refs/convert/parquet', sourceId: '/part1/part2/file.txt?branch=refs/convert/parquet' }, 29 | ], 30 | }) 31 | }, 32 | }, 33 | }, 34 | render: (args) => { 35 | const config = { 36 | routes: { 37 | getSourceRouteUrl: ({ sourceId }: { sourceId: string }) => `/files?key=${sourceId}`, 38 | }, 39 | customClass: { 40 | versions: 'custom-versions', 41 | }, 42 | } 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | ) 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/Breadcrumb.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { strict as assert } from 'assert' 3 | import { describe, expect, it } from 'vitest' 4 | import { Config, ConfigProvider } from '../../hooks/useConfig.js' 5 | import { getHyperparamSource } from '../../lib/sources/hyperparamSource.js' 6 | import Breadcrumb from './Breadcrumb.js' 7 | 8 | const endpoint = 'http://localhost:3000' 9 | 10 | describe('Breadcrumb Component', () => { 11 | it('renders breadcrumbs correctly', () => { 12 | const source = getHyperparamSource('subdir1/subdir2/', { endpoint }) 13 | assert(source !== undefined) 14 | 15 | const config: Config = { 16 | routes: { 17 | getSourceRouteUrl: ({ sourceId }) => `/files?key=${sourceId}`, 18 | }, 19 | } 20 | const { getByText } = render( 21 | 22 | ) 23 | 24 | const subdir1Link = getByText('subdir1/') 25 | expect(subdir1Link.closest('a')?.getAttribute('href')).toBe('/files?key=subdir1/') 26 | 27 | const subdir2Link = getByText('subdir2/') 28 | expect(subdir2Link.closest('a')?.getAttribute('href')).toBe('/files?key=subdir1/subdir2/') 29 | }) 30 | 31 | it('handles versions correctly', async () => { 32 | const source = getHyperparamSource('subdir1/subdir2/', { endpoint }) 33 | assert(source !== undefined) 34 | source.fetchVersions = () => { 35 | return Promise.resolve({ 36 | label: 'Versions', 37 | versions: [ 38 | { label: 'v1.0', sourceId: 'v1.0' }, 39 | { label: 'v2.0', sourceId: 'v2.0' }, 40 | ], 41 | }) 42 | } 43 | 44 | const config: Config = { 45 | routes: { 46 | getSourceRouteUrl: ({ sourceId }) => `/files?key=${sourceId}`, 47 | }, 48 | } 49 | const { findByText, getAllByRole } = render( 50 | 51 | ) 52 | 53 | const versionsLabel = await findByText('Versions') 54 | expect(versionsLabel).toBeDefined() 55 | const versionLinks = getAllByRole('menuitem') 56 | expect(versionLinks.length).toBe(2) 57 | expect(versionLinks[0]?.getAttribute('href')).toBe('/files?key=v1.0') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { useEffect, useState } from 'react' 3 | import { useConfig } from '../../hooks/useConfig.js' 4 | import type { Source, VersionsData } from '../../lib/sources/types.js' 5 | import { cn } from '../../lib/utils.js' 6 | import Dropdown from '../Dropdown/Dropdown.js' 7 | import styles from './Breadcrumb.module.css' 8 | 9 | interface BreadcrumbProps { 10 | source: Source, 11 | children?: ReactNode 12 | } 13 | 14 | function Versions({ source }: { source: Source }) { 15 | const { routes, customClass } = useConfig() 16 | const [versionsData, setVersionsData] = useState(undefined) 17 | 18 | useEffect(() => { 19 | source.fetchVersions?.().then( 20 | (nextVersionData) => { 21 | setVersionsData(nextVersionData) 22 | } 23 | ).catch((error: unknown) => { 24 | console.error('Error fetching versions:', error) 25 | setVersionsData(undefined) 26 | }) 27 | }, [source]) 28 | 29 | if (!versionsData) return null 30 | const { label, versions } = versionsData 31 | 32 | return 33 | {versions.map(({ label, sourceId }) => { 34 | return {label} 40 | })} 41 | 42 | } 43 | 44 | /** 45 | * Breadcrumb navigation 46 | */ 47 | export default function Breadcrumb({ source, children }: BreadcrumbProps) { 48 | const { routes, customClass } = useConfig() 49 | 50 | return 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Cell/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { stringify } from 'hightable' 2 | import { asyncBufferFromUrl, parquetMetadataAsync } from 'hyparquet' 3 | import { useEffect, useState } from 'react' 4 | import { useConfig } from '../../hooks/useConfig.js' 5 | import type { FileSource } from '../../lib/sources/types.js' 6 | import { parquetDataFrame } from '../../lib/tableProvider.js' 7 | import { cn } from '../../lib/utils.js' 8 | import Breadcrumb from '../Breadcrumb/Breadcrumb.js' 9 | import Layout from '../Layout/Layout.js' 10 | import styles from '../TextView/TextView.module.css' 11 | 12 | interface CellProps { 13 | source: FileSource 14 | row: number 15 | col: number 16 | } 17 | 18 | /** 19 | * Cell viewer displays a single cell from a table. 20 | */ 21 | export default function CellView({ source, row, col }: CellProps) { 22 | const [text, setText] = useState() 23 | const [progress, setProgress] = useState() 24 | const [error, setError] = useState() 25 | const { customClass } = useConfig() 26 | 27 | // File path from url 28 | const { resolveUrl, requestInit, fileName } = source 29 | 30 | // Load cell data 31 | useEffect(() => { 32 | async function loadCellData() { 33 | try { 34 | // TODO: handle first row > 100kb 35 | setProgress(0.25) 36 | const asyncBuffer = await asyncBufferFromUrl({ url: resolveUrl, requestInit }) 37 | const from = { url: resolveUrl, requestInit, byteLength: asyncBuffer.byteLength } 38 | setProgress(0.5) 39 | const metadata = await parquetMetadataAsync(asyncBuffer) 40 | setProgress(0.75) 41 | const df = parquetDataFrame(from, metadata) 42 | const asyncRows = df.rows({ start: row, end: row + 1 }) 43 | if (asyncRows.length > 1 || !(0 in asyncRows)) { 44 | throw new Error(`Expected 1 row, got ${asyncRows.length}`) 45 | } 46 | const asyncRow = asyncRows[0] 47 | // Await cell data 48 | const columnName = df.header[col] 49 | if (columnName === undefined) { 50 | throw new Error(`Column name missing at index col=${col}`) 51 | } 52 | const asyncCell = asyncRow.cells[columnName] 53 | if (asyncCell === undefined) { 54 | throw new Error(`Cell missing at column ${columnName}`) 55 | } 56 | const text = await asyncCell.then(stringify) 57 | setText(text) 58 | setError(undefined) 59 | } catch (error) { 60 | setError(error as Error) 61 | setText(undefined) 62 | } finally { 63 | setProgress(undefined) 64 | } 65 | } 66 | 67 | setProgress(0) 68 | void loadCellData() 69 | }, [resolveUrl, requestInit, col, row]) 70 | 71 | return ( 72 | 73 | 74 | 75 | {/* */} 76 |
{text}
77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/components/CellPanel/CellPanel.tsx: -------------------------------------------------------------------------------- 1 | import { DataFrame, stringify } from 'hightable' 2 | import { useEffect, useState } from 'react' 3 | import { useConfig } from '../../hooks/useConfig.js' 4 | import { cn } from '../../lib/utils.js' 5 | import ContentWrapper from '../ContentWrapper/ContentWrapper.js' 6 | import SlideCloseButton from '../SlideCloseButton/SlideCloseButton.js' 7 | import styles from '../TextView/TextView.module.css' 8 | 9 | interface ViewerProps { 10 | df: DataFrame 11 | row: number 12 | col: number 13 | setProgress: (progress: number) => void 14 | setError: (error: Error) => void 15 | onClose: () => void 16 | } 17 | 18 | /** 19 | * Cell viewer displays a single cell from a table. 20 | */ 21 | export default function CellPanel({ df, row, col, setProgress, setError, onClose }: ViewerProps) { 22 | const [text, setText] = useState() 23 | const { customClass } = useConfig() 24 | 25 | // Load cell data 26 | useEffect(() => { 27 | async function loadCellData() { 28 | try { 29 | setProgress(0.5) 30 | const asyncRows = df.rows({ start: row, end: row + 1 }) 31 | if (asyncRows.length > 1 || !(0 in asyncRows)) { 32 | throw new Error(`Expected 1 row, got ${asyncRows.length}`) 33 | } 34 | const asyncRow = asyncRows[0] 35 | // Await cell data 36 | const columnName = df.header[col] 37 | if (columnName === undefined) { 38 | throw new Error(`Column name missing at index col=${col}`) 39 | } 40 | const asyncCell = asyncRow.cells[columnName] 41 | if (asyncCell === undefined) { 42 | throw new Error(`Cell missing at column ${columnName}`) 43 | } 44 | const text = await asyncCell.then(stringify) 45 | setText(text) 46 | } catch (error) { 47 | setError(error as Error) 48 | } finally { 49 | setProgress(1) 50 | } 51 | } 52 | 53 | void loadCellData() 54 | }, [df, col, row, setProgress, setError]) 55 | 56 | const headers = <> 57 | 58 | column `{df.header[col]}` 59 | row {row + 1} 60 | 61 | 62 | return 63 | {text} 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Center/Center.module.css: -------------------------------------------------------------------------------- 1 | .center { 2 | flex: 1; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Center/Center.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { cn } from '../../lib/utils.js' 4 | import styles from './Center.module.css' 5 | 6 | export default function Center({ children }: {children?: ReactNode}) { 7 | const { customClass } = useConfig() 8 | return
{children}
9 | } 10 | -------------------------------------------------------------------------------- /src/components/ContentWrapper/ContentWrapper.module.css: -------------------------------------------------------------------------------- 1 | .contentWrapper { 2 | display: flex; 3 | flex: 1; 4 | flex-direction: column; 5 | white-space: pre-wrap; 6 | overflow-y: auto; 7 | 8 | & > header:first-child { 9 | align-items: center; 10 | background-color: #f2f2f2; 11 | color: #444; 12 | display: flex; 13 | gap: 16px; 14 | height: 24px; 15 | overflow: hidden; 16 | padding: 0 16px; 17 | /* all one line */ 18 | text-overflow: ellipsis; 19 | white-space: nowrap; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ContentWrapper/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { cn, formatFileSize } from '../../lib/utils.js' 4 | import Center from '../Center/Center.js' 5 | import Spinner from '../Spinner/Spinner.js' 6 | import styles from './ContentWrapper.module.css' 7 | 8 | export interface ContentSize { 9 | fileSize?: number 10 | } 11 | 12 | export interface TextContent extends ContentSize { 13 | text: string 14 | } 15 | 16 | interface ContentWrapperProps { 17 | content?: ContentSize 18 | headers?: ReactNode 19 | isLoading?: boolean 20 | children?: ReactNode 21 | } 22 | 23 | export default function ContentWrapper({ content, headers, isLoading, children }: ContentWrapperProps) { 24 | const { customClass } = useConfig() 25 | return
26 |
27 | {content?.fileSize && 28 | {formatFileSize(content.fileSize)} 29 | } 30 | {headers} 31 |
32 | {isLoading ?
: children } 33 |
34 | } 35 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.module.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | display: inline-block; 3 | position: relative; 4 | text-overflow: ellipsis; 5 | user-select: none; 6 | white-space: nowrap; 7 | } 8 | 9 | .dropdownButton, 10 | .dropdownButton:active, 11 | .dropdownButton:focus, 12 | .dropdownButton:hover { 13 | align-items: center; 14 | background: inherit; 15 | border: none; 16 | color: inherit; 17 | cursor: pointer; 18 | display: flex; 19 | font-size: initial; 20 | overflow-x: hidden; 21 | padding: 0; 22 | } 23 | .dropdownButton:active, 24 | .dropdownButton:focus, 25 | .dropdownButton:hover { 26 | color: #113; 27 | } 28 | 29 | /* caret */ 30 | .dropdownButton::before { 31 | content: "\25bc"; 32 | display: inline-block; 33 | font-size: 10px; 34 | margin-right: 4px; 35 | transform: rotate(-90deg); 36 | transition: transform 0.1s; 37 | } 38 | .dropdown:has([aria-expanded="true"]) .dropdownButton::before { 39 | transform: rotate(0deg); 40 | } 41 | 42 | /* alignment */ 43 | .dropdownLeft .dropdownContent { 44 | left: 0; 45 | } 46 | 47 | .dropdownContent { 48 | background-color: #eee; 49 | position: absolute; 50 | right: 0; 51 | border-radius: 6px; 52 | box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); 53 | display: flex; 54 | flex-direction: column; 55 | max-height: 0; 56 | max-width: 300px; 57 | min-width: 160px; 58 | transition: max-height 0.1s ease-out; 59 | overflow-y: hidden; 60 | z-index: 20; 61 | } 62 | 63 | .dropdown:has([aria-expanded="true"]) .dropdownContent { 64 | max-height: 170px; 65 | overflow-y: auto; 66 | } 67 | 68 | .dropdownContent > * { 69 | display: block; 70 | } 71 | 72 | .dropdownContent a, 73 | .dropdownContent button { 74 | background: none; 75 | border: none; 76 | border-radius: 0; 77 | color: inherit; 78 | flex-shrink: 0; 79 | font-size: 12px; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | padding: 8px 16px; 83 | text-align: left; 84 | text-decoration: none; 85 | width: 100%; 86 | } 87 | .dropdownContent a:active, 88 | .dropdownContent a:focus, 89 | .dropdownContent a:hover, 90 | .dropdownContent button:active, 91 | .dropdownContent button:focus, 92 | .dropdownContent button:hover { 93 | background-color: rgba(31, 30, 33, 0.1); 94 | } 95 | .dropdownContent input { 96 | margin: 4px 8px; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import Dropdown from './Dropdown.js' 3 | 4 | const meta: Meta = { 5 | component: Dropdown, 6 | } 7 | export default meta 8 | type Story = StoryObj; 9 | export const Default: Story = { 10 | args: { 11 | label: 'Menu', 12 | children: <> 13 | 14 | 15 | 16 | , 17 | }, 18 | } 19 | 20 | export const LeftAlign: Story = { 21 | args: { 22 | label: 'Menu', 23 | align: 'left', 24 | children: <> 25 | 26 | 27 | 28 | , 29 | }, 30 | } 31 | 32 | export const RightAlign: Story = { 33 | args: { 34 | label: 'Very long label for the menu', 35 | align: 'right', 36 | children: <> 37 | 38 | 39 | 40 | , 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react' 2 | import { describe, expect, it, vi } from 'vitest' 3 | import Dropdown from './Dropdown' 4 | import styles from './Dropdown.module.css' 5 | 6 | describe('Dropdown Component', () => { 7 | it('renders dropdown with its children', () => { 8 | const { container: { children: [ div ] }, queryByText } = render( 9 |
Child 1
Child 2
10 | ) 11 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('false') 12 | expect(queryByText('Child 1')).toBeDefined() 13 | expect(queryByText('Child 2')).toBeDefined() 14 | expect(div?.classList).toContain(styles.dropdownLeft) 15 | }) 16 | 17 | it('toggles dropdown content on button click', () => { 18 | const { container: { children: [ div ] }, getByRole } = render( 19 | 20 |
Child 1
21 |
Child 2
22 |
23 | ) 24 | const dropdownButton = getByRole('button') 25 | 26 | // open menu with click 27 | fireEvent.click(dropdownButton) 28 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true') 29 | 30 | // click again to close 31 | fireEvent.click(dropdownButton) 32 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('false') 33 | }) 34 | 35 | it('closes dropdown when clicking outside', () => { 36 | const { container: { children: [ div ] }, getByRole } = render( 37 | 38 |
Child 1
39 |
Child 2
40 |
41 | ) 42 | 43 | const dropdownButton = getByRole('button') 44 | fireEvent.click(dropdownButton) // open dropdown 45 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true') 46 | 47 | // Simulate a click outside 48 | fireEvent.mouseDown(document) 49 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('false') 50 | }) 51 | 52 | it('does not close dropdown when clicking inside', () => { 53 | const { container: { children: [ div ] }, getByRole, getByText } = render( 54 | 55 |
Child 1
56 |
Child 2
57 |
58 | ) 59 | 60 | const dropdownButton = getByRole('button') 61 | fireEvent.click(dropdownButton) // open dropdown 62 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true') 63 | 64 | const dropdownContent = getByText('Child 1').parentElement 65 | if (!dropdownContent) throw new Error('Dropdown content not found') 66 | fireEvent.mouseDown(dropdownContent) 67 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true') 68 | }) 69 | 70 | it('closes dropdown on escape key press', () => { 71 | const { container: { children: [ div ] }, getByRole } = render( 72 | 73 |
Child 1
74 |
Child 2
75 |
76 | ) 77 | 78 | const dropdownButton = getByRole('button') 79 | fireEvent.click(dropdownButton) // open dropdown 80 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true') 81 | 82 | // Press escape key 83 | fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) 84 | expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('false') 85 | }) 86 | 87 | it('adds dropdownLeft class when align is left', () => { 88 | const { container: { children: [ div ] } } = render( 89 |
Child 1
Child 2
90 | ) 91 | expect(div?.classList).toContain(styles.dropdownLeft) 92 | }) 93 | 94 | it('cleans up event listeners on unmount', () => { 95 | const { unmount } = render(
Dropdown Content
) 96 | 97 | // Mock function to replace the actual document event listener 98 | const mockRemoveEventListener = vi.spyOn(document, 'removeEventListener') 99 | 100 | // Unmount the component 101 | unmount() 102 | 103 | // Check if the event listener was removed 104 | expect(mockRemoveEventListener).toHaveBeenCalledWith('click', expect.any(Function)) 105 | expect(mockRemoveEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)) 106 | expect(mockRemoveEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function)) 107 | }) 108 | 109 | // Keyboard navigation tests 110 | it('opens dropdown and focuses first item on ArrowDown when closed', () => { 111 | const { getByRole, getAllByRole } = render( 112 | 113 | 114 | 115 | 116 | ) 117 | const menuItems = getAllByRole('menuitem') 118 | const dropdownButton = getByRole('button') 119 | 120 | // initially closed 121 | expect(dropdownButton.getAttribute('aria-expanded')).toBe('false') 122 | 123 | // down arrow to open menu 124 | fireEvent.keyDown(dropdownButton, { key: 'ArrowDown', code: 'ArrowDown' }) 125 | expect(dropdownButton.getAttribute('aria-expanded')).toBe('true') 126 | 127 | // first menu item should be focused 128 | expect(document.activeElement).toBe(menuItems[0]) 129 | }) 130 | 131 | it('focuses the next item on ArrowDown and wraps to first item if at the end', () => { 132 | const { getByRole, getAllByRole } = render( 133 | 134 | 135 | 136 | 137 | ) 138 | const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement] 139 | const dropdownButton = getByRole('button') 140 | 141 | // open menu, first item has focus 142 | fireEvent.click(dropdownButton) 143 | expect(document.activeElement).toBe(menuItems[0]) 144 | 145 | // second item should be focused 146 | fireEvent.keyDown(menuItems[0], { key: 'ArrowDown', code: 'ArrowDown' }) 147 | expect(document.activeElement).toBe(menuItems[1]) 148 | 149 | // wrap back to first item 150 | fireEvent.keyDown(menuItems[1], { key: 'ArrowDown', code: 'ArrowDown' }) 151 | expect(document.activeElement).toBe(menuItems[0]) 152 | }) 153 | 154 | it('focuses the previous item on ArrowUp and wraps to the last item if at the top', () => { 155 | const { getByRole, getAllByRole } = render( 156 | 157 | 158 | 159 | 160 | ) 161 | const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement] 162 | const dropdownButton = getByRole('button') 163 | 164 | // open menu, first item has focus 165 | fireEvent.click(dropdownButton) 166 | expect(document.activeElement).toBe(menuItems[0]) 167 | 168 | // ArrowUp -> should wrap to last item 169 | fireEvent.keyDown(menuItems[0], { key: 'ArrowUp', code: 'ArrowUp' }) 170 | expect(document.activeElement).toBe(menuItems[1]) 171 | }) 172 | 173 | it('focuses first item on Home key press', () => { 174 | const { getByRole, getAllByRole } = render( 175 | 176 | 177 | 178 | 179 | 180 | ) 181 | const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement, HTMLElement] 182 | const dropdownButton = getByRole('button') 183 | 184 | // open menu, first item has focus 185 | fireEvent.click(dropdownButton) 186 | expect(document.activeElement).toBe(menuItems[0]) 187 | 188 | // move to the second item 189 | fireEvent.keyDown(menuItems[0], { key: 'ArrowDown', code: 'ArrowDown' }) 190 | expect(document.activeElement).toBe(menuItems[1]) 191 | 192 | // Home key should focus first item 193 | fireEvent.keyDown(menuItems[1], { key: 'Home', code: 'Home' }) 194 | expect(document.activeElement).toBe(menuItems[0]) 195 | }) 196 | 197 | it('focuses last item on End key press', () => { 198 | const { getByRole, getAllByRole } = render( 199 | 200 | 201 | 202 | 203 | 204 | ) 205 | const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement, HTMLElement] 206 | const dropdownButton = getByRole('button') 207 | 208 | // open menu, first item has focus 209 | fireEvent.click(dropdownButton) 210 | expect(document.activeElement).toBe(menuItems[0]) 211 | 212 | // End key should focus the last item 213 | fireEvent.keyDown(menuItems[0], { key: 'End', code: 'End' }) 214 | expect(document.activeElement).toBe(menuItems[2]) 215 | }) 216 | 217 | it('closes the menu and puts focus back on the button on Escape', () => { 218 | const { getByRole, getAllByRole } = render( 219 | 220 | 221 | 222 | 223 | ) 224 | const menuItems = getAllByRole('menuitem') as [HTMLElement] 225 | const dropdownButton = getByRole('button') 226 | 227 | // open menu, first item has focus 228 | fireEvent.click(dropdownButton) 229 | expect(document.activeElement).toBe(menuItems[0]) 230 | expect(dropdownButton.getAttribute('aria-expanded')).toBe('true') 231 | 232 | // escape closes menu 233 | fireEvent.keyDown(menuItems[0], { key: 'Escape', code: 'Escape' }) 234 | expect(dropdownButton.getAttribute('aria-expanded')).toBe('false') 235 | 236 | // focus returns to the button 237 | expect(document.activeElement).toBe(dropdownButton) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /src/components/Dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useEffect, useRef, useState } from 'react' 2 | import { cn } from '../../lib/utils' 3 | import styles from './Dropdown.module.css' 4 | 5 | interface DropdownProps { 6 | label?: string 7 | align?: 'left' | 'right' 8 | className?: string 9 | children: ReactNode 10 | } 11 | 12 | /** 13 | * Dropdown menu component. 14 | * 15 | * @example 16 | * 17 | * 18 | * 19 | * 20 | */ 21 | export default function Dropdown({ label, align = 'left', className, children }: DropdownProps) { 22 | const [isOpen, setIsOpen] = useState(false) 23 | const [focusedIndex, setFocusedIndex] = useState(-1) 24 | 25 | const dropdownRef = useRef(null) 26 | const menuRef = useRef(null) 27 | const buttonRef = useRef(null) 28 | 29 | // Helper to get all focusable items in the dropdown menu 30 | function getFocusableMenuItems(): HTMLElement[] { 31 | if (!menuRef.current) return [] 32 | return Array.from(menuRef.current.querySelectorAll( 33 | 'button, [href], input, select, textarea, [role="menuitem"]' 34 | )) 35 | } 36 | 37 | function toggleDropdown() { 38 | setIsOpen(prev => !prev) 39 | } 40 | 41 | // reset focus so we start at the first item 42 | useEffect(() => { 43 | if (isOpen) setFocusedIndex(0) 44 | }, [isOpen]) 45 | 46 | // whenever focusedIndex changes, focus the corresponding menu item 47 | useEffect(() => { 48 | if (isOpen && focusedIndex >= 0) { 49 | const items = getFocusableMenuItems() 50 | items[focusedIndex]?.focus() 51 | } 52 | }, [isOpen, focusedIndex]) 53 | 54 | // handle key presses 55 | function handleKeyDown(event: React.KeyboardEvent) { 56 | const items = getFocusableMenuItems() 57 | if (!items.length) return 58 | 59 | switch (event.key) { 60 | case 'ArrowDown': 61 | event.preventDefault() 62 | setFocusedIndex(prev => (prev + 1) % items.length) 63 | if (!isOpen) { 64 | setIsOpen(true) 65 | } 66 | break 67 | case 'ArrowUp': 68 | event.preventDefault() 69 | setFocusedIndex(prev => (prev - 1 + items.length) % items.length) 70 | break 71 | case 'Home': 72 | event.preventDefault() 73 | setFocusedIndex(0) 74 | break 75 | case 'End': 76 | event.preventDefault() 77 | setFocusedIndex(items.length - 1) 78 | break 79 | case 'Escape': 80 | event.preventDefault() 81 | setIsOpen(false) 82 | buttonRef.current?.focus() 83 | break 84 | default: 85 | break 86 | } 87 | } 88 | 89 | // close dropdown if user clicks outside or presses escape 90 | useEffect(() => { 91 | function handleClickInside(event: MouseEvent) { 92 | const target = event.target as Element 93 | // if a child is clicked (and it's not an input), close the dropdown 94 | if (menuRef.current && menuRef.current.contains(target) && target.tagName !== 'INPUT') { 95 | setIsOpen(false) 96 | } 97 | } 98 | 99 | function handleClickOutside(event: MouseEvent) { 100 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 101 | setIsOpen(false) 102 | } 103 | } 104 | 105 | function handleEscape(event: KeyboardEvent) { 106 | if (event.key === 'Escape') { 107 | setIsOpen(false) 108 | } 109 | } 110 | 111 | document.addEventListener('click', handleClickInside) 112 | document.addEventListener('keydown', handleEscape) 113 | document.addEventListener('mousedown', handleClickOutside) 114 | return () => { 115 | document.removeEventListener('click', handleClickInside) 116 | document.removeEventListener('keydown', handleEscape) 117 | document.removeEventListener('mousedown', handleClickOutside) 118 | } 119 | }, []) 120 | 121 | return ( 122 |
126 | 135 | 136 |
141 | {children} 142 |
143 |
144 | ) 145 | } 146 | -------------------------------------------------------------------------------- /src/components/ErrorBar/ErrorBar.module.css: -------------------------------------------------------------------------------- 1 | .errorBar { 2 | display: none; 3 | max-height: 30%; 4 | padding: 0; 5 | background-color: #dd111199; 6 | overflow: hidden; 7 | transition: max-height 0.3s; 8 | white-space: pre-wrap; 9 | * { 10 | font-family: monospace; 11 | } 12 | 13 | &[data-visible="true"] { 14 | display: block; 15 | } 16 | 17 | & > div { 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-between; 21 | min-height: 100%; 22 | overflow-y: auto; 23 | padding: 10px 10px 10px 20px; 24 | } 25 | 26 | /* close button */ 27 | button, 28 | button:active, 29 | button:focus, 30 | button:focus-visible, 31 | button:hover { 32 | background: none; 33 | border: none; 34 | border-radius: 4px; 35 | padding: 0 8px; 36 | cursor: pointer; 37 | color: #333; 38 | font-size: 30px; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | transition: background-color 0.2s; 43 | } 44 | button:active { 45 | background-color: rgba(0, 0, 0, 0.2); 46 | } 47 | button:focus { 48 | background-color: rgba(0, 0, 0, 0.1); 49 | outline: 2px solid #a44; 50 | } 51 | button:hover { 52 | background-color: rgba(0, 0, 0, 0.1); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ErrorBar/ErrorBar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { cn } from '../../lib/utils.js' 4 | import styles from './ErrorBar.module.css' 5 | 6 | interface ErrorBarProps { 7 | error?: Error 8 | } 9 | 10 | export default function ErrorBar({ error }: ErrorBarProps) { 11 | const [showError, setShowError] = useState(error !== undefined) 12 | const [prevError, setPrevError] = useState(error) 13 | const { customClass } = useConfig() 14 | 15 | if (error) console.error(error) 16 | /// Reset error visibility when error prop changes 17 | if (error !== prevError) { 18 | setPrevError(error) 19 | setShowError(error !== undefined) 20 | } 21 | 22 | return
26 |
27 | {error?.toString()} 28 | 33 |
34 |
35 | } 36 | -------------------------------------------------------------------------------- /src/components/File/File.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { strict as assert } from 'assert' 3 | import { act } from 'react' 4 | import { describe, expect, it, vi } from 'vitest' 5 | import { Config, ConfigProvider } from '../../hooks/useConfig.js' 6 | import { getHttpSource, getHyperparamSource } from '../../lib/sources/index.js' 7 | import File from './File.js' 8 | 9 | const endpoint = 'http://localhost:3000' 10 | 11 | const config: Config = { 12 | routes: { 13 | getSourceRouteUrl: ({ sourceId }) => `/files?key=${sourceId}`, 14 | getCellRouteUrl: ({ sourceId, col, row }) => `/files?key=${sourceId}&col=${col}&row=${row}`, 15 | }, 16 | } 17 | 18 | // Mock fetch 19 | const text = vi.fn() 20 | const headers = { get: vi.fn() } 21 | globalThis.fetch = vi.fn(() => Promise.resolve({ text, headers } as unknown as Response)) 22 | 23 | describe('File Component', () => { 24 | it('renders a local file path', async () => { 25 | text.mockResolvedValueOnce('test content') 26 | const source = getHyperparamSource('folder/subfolder/test.txt', { endpoint }) 27 | assert(source?.kind === 'file') 28 | 29 | const { getByText } = await act(() => render( 30 | 31 | 32 | 33 | )) 34 | 35 | expect(getByText('/')).toBeDefined() 36 | expect(getByText('folder/')).toBeDefined() 37 | expect(getByText('subfolder/')).toBeDefined() 38 | expect(getByText('test.txt')).toBeDefined() 39 | }) 40 | 41 | it('renders a URL', async () => { 42 | text.mockResolvedValueOnce('test content') 43 | const url = 'https://example.com/test.txt' 44 | const source = getHttpSource(url) 45 | assert(source?.kind === 'file') 46 | 47 | const { getAllByRole } = await act(() => render( 48 | 49 | 50 | 51 | )) 52 | 53 | const links = getAllByRole('link') 54 | expect(links[0]?.getAttribute('href')).toBe('/') 55 | expect(links[1]?.getAttribute('href')).toBe('/files?key=https://example.com/') 56 | expect(links[2]?.getAttribute('href')).toBe('/files?key=https://example.com/test.txt') 57 | }) 58 | 59 | it('renders correct breadcrumbs for nested folders', async () => { 60 | text.mockResolvedValueOnce('test content') 61 | const source = getHyperparamSource('folder1/folder2/folder3/test.txt', { endpoint }) 62 | assert(source?.kind === 'file') 63 | 64 | const { getAllByRole } = await act(() => render( 65 | 66 | 67 | 68 | )) 69 | 70 | const links = getAllByRole('link') 71 | expect(links[0]?.getAttribute('href')).toBe('/') 72 | expect(links[1]?.getAttribute('href')).toBe('/files?key=') 73 | expect(links[2]?.getAttribute('href')).toBe('/files?key=folder1/') 74 | expect(links[3]?.getAttribute('href')).toBe('/files?key=folder1/folder2/') 75 | expect(links[4]?.getAttribute('href')).toBe('/files?key=folder1/folder2/folder3/') 76 | expect(links[5]?.getAttribute('href')).toBe('/files?key=folder1/folder2/folder3/test.txt') 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/components/File/File.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import type { FileSource } from '../../lib/sources/types.js' 3 | import Breadcrumb from '../Breadcrumb/Breadcrumb.js' 4 | import Layout from '../Layout/Layout.js' 5 | import Viewer from '../Viewer/Viewer.js' 6 | 7 | interface FileProps { 8 | source: FileSource 9 | } 10 | 11 | /** 12 | * File viewer page 13 | */ 14 | export default function File({ source }: FileProps) { 15 | const [progress, setProgress] = useState() 16 | const [error, setError] = useState() 17 | 18 | return 19 | 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Folder/Folder.module.css: -------------------------------------------------------------------------------- 1 | .fileList { 2 | /* file list */ 3 | flex: 1; 4 | list-style: none; 5 | overflow-y: auto; 6 | padding-left: 0; 7 | /* browsers like to cover the bottom row */ 8 | padding-bottom: 24px; 9 | 10 | & > li { 11 | margin: 0; 12 | 13 | &:first-child > a { 14 | border-top: none; 15 | } 16 | 17 | & > a { 18 | border-top: 1px solid #ddd; 19 | color: #444; 20 | display: flex; 21 | padding: 8px 16px 8px 20px; 22 | text-decoration: none; 23 | 24 | &:hover { 25 | background-color: #e2e2ee; 26 | } 27 | 28 | & > span { 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | white-space: nowrap; 32 | 33 | &[data-file-kind] { 34 | /* file name + icon */ 35 | flex: 1; 36 | min-width: 80px; 37 | 38 | background-position: left center; 39 | background-repeat: no-repeat; 40 | background-size: 12px; 41 | padding-left: 22px; 42 | 43 | &[data-file-kind="directory"] { 44 | background-image: url("../../assets/folder.svg"); 45 | } 46 | 47 | &[data-file-kind="file"] { 48 | background-image: url("../../assets/file.svg"); 49 | } 50 | } 51 | 52 | &[data-file-size] { 53 | /* file size */ 54 | color: #666; 55 | margin: 0 16px; 56 | text-align: right; 57 | } 58 | 59 | &[data-file-date] { 60 | /* file date */ 61 | min-width: 90px; 62 | text-align: right; 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | /* search */ 70 | .search { 71 | background: #fff url("../../assets/search.svg") no-repeat center right 8px; 72 | border: 1px solid transparent; 73 | border-radius: 8px; 74 | flex-shrink: 1; 75 | font-size: 12px; 76 | height: 24px; 77 | min-width: 0; 78 | outline: none; 79 | padding: 4px 20px 2px 8px; 80 | width: 100px; 81 | transition-duration: 0.3s; 82 | transition-property: border, width; 83 | 84 | &:focus { 85 | border-color: #778; 86 | box-shadow: 0 0 1px #556; 87 | color: #444; 88 | padding-left: 8px; 89 | width: 180px; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Folder/Folder.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, waitFor } from '@testing-library/react' 2 | import { strict as assert } from 'assert' 3 | import { act } from 'react' 4 | import { describe, expect, it, test, vi } from 'vitest' 5 | import { Config, ConfigProvider } from '../../hooks/useConfig.js' 6 | import { DirSource, FileMetadata, HyperparamFileMetadata, getHyperparamSource } from '../../lib/sources/index.js' 7 | import Folder from './Folder.js' 8 | 9 | const endpoint = 'http://localhost:3000' 10 | const mockFiles: HyperparamFileMetadata[] = [ 11 | { key: 'folder1/', lastModified: '2022-01-01T12:00:00Z' }, 12 | { key: 'file1.txt', fileSize: 8196, lastModified: '2023-01-01T12:00:00Z' }, 13 | ] 14 | 15 | const config: Config = { 16 | routes: { 17 | getSourceRouteUrl: ({ sourceId }) => `/files?key=${sourceId}`, 18 | }, 19 | } 20 | 21 | globalThis.fetch = vi.fn() 22 | 23 | describe('Folder Component', () => { 24 | test.for([ 25 | '', 26 | 'subfolder/', 27 | ])('fetches file data and displays files on mount', async (path) => { 28 | vi.mocked(fetch).mockResolvedValueOnce({ 29 | json: () => Promise.resolve(mockFiles), 30 | ok: true, 31 | } as Response) 32 | 33 | const source = getHyperparamSource(path, { endpoint }) 34 | assert(source?.kind === 'directory') 35 | 36 | const { findByText, getByText } = render( 37 | 38 | 39 | ) 40 | 41 | const folderLink = await findByText('folder1/') 42 | expect(folderLink.closest('a')?.getAttribute('href')).toBe(`/files?key=${path}folder1/`) 43 | 44 | expect(getByText('/')).toBeDefined() 45 | 46 | const fileLink = getByText('file1.txt') 47 | expect(fileLink.closest('a')?.getAttribute('href')).toBe(`/files?key=${path}file1.txt`) 48 | expect(getByText('8.0 kb')).toBeDefined() 49 | expect(getByText('1/1/2023')).toBeDefined() 50 | }) 51 | 52 | it('displays the spinner while loading', async () => { 53 | vi.mocked(fetch).mockResolvedValueOnce({ 54 | // resolve in 50ms 55 | json: () => new Promise(resolve => setTimeout(() => { resolve([]) }, 50)), 56 | ok: true, 57 | } as Response) 58 | 59 | const source = getHyperparamSource('', { endpoint }) 60 | assert(source?.kind === 'directory') 61 | 62 | const { getByText } = await act(() => render()) 63 | const spinner = getByText('Loading...') 64 | expect(spinner).toBeDefined() 65 | }) 66 | 67 | it('handles file listing errors', async () => { 68 | const errorMessage = 'Failed to fetch' 69 | vi.mocked(fetch).mockResolvedValueOnce({ 70 | text: () => Promise.resolve(errorMessage), 71 | ok: false, 72 | } as Response) 73 | 74 | const source = getHyperparamSource('test-prefix/', { endpoint }) 75 | assert(source?.kind === 'directory') 76 | 77 | const { findByText, queryByText } = render() 78 | 79 | await waitFor(() => { expect(fetch).toHaveBeenCalled() }) 80 | 81 | await findByText('Error: ' + errorMessage) 82 | expect(queryByText('file1.txt')).toBeNull() 83 | expect(queryByText('folder1/')).toBeNull() 84 | }) 85 | 86 | it('filters files based on search query', async () => { 87 | const mockFiles: FileMetadata[] = [ 88 | { sourceId: 'folder1', name: 'folder1/', kind: 'directory', lastModified: '2023-01-01T00:00:00Z' }, 89 | { sourceId: 'file1.txt', name: 'file1.txt', kind: 'file', size: 8196, lastModified: '2023-01-01T00:00:00Z' }, 90 | { sourceId: 'report.pdf', name: 'report.pdf', kind: 'file', size: 10240, lastModified: '2023-01-02T00:00:00Z' }, 91 | ] 92 | const dirSource: DirSource = { 93 | sourceId: 'test-source', 94 | sourceParts: [{ text: 'test-source', sourceId: 'test-source' }], 95 | kind: 'directory', 96 | prefix: '', 97 | listFiles: () => Promise.resolve(mockFiles), 98 | } 99 | const { getByPlaceholderText, getByText, queryByText } = render() 100 | 101 | // Type a search query 102 | const searchInput = getByPlaceholderText('Search...') as HTMLInputElement 103 | act(() => { 104 | fireEvent.keyUp(searchInput, { target: { value: 'file1' } }) 105 | }) 106 | 107 | // Only matching files are displayed 108 | await waitFor(() => { 109 | expect(getByText('file1.txt')).toBeDefined() 110 | expect(queryByText('folder1/')).toBeNull() 111 | expect(queryByText('report.pdf')).toBeNull() 112 | }) 113 | 114 | // Clear search with escape key 115 | act(() => { 116 | fireEvent.keyUp(searchInput, { key: 'Escape' }) 117 | }) 118 | 119 | await waitFor(() => { 120 | expect(getByText('file1.txt')).toBeDefined() 121 | expect(getByText('folder1/')).toBeDefined() 122 | expect(getByText('report.pdf')).toBeDefined() 123 | }) 124 | }) 125 | 126 | it('hitting enter on single search result navigates to file', async () => { 127 | // Mock location.href 128 | const location = { href: '' } 129 | Object.defineProperty(window, 'location', { 130 | writable: true, 131 | value: location, 132 | }) 133 | const mockFiles: FileMetadata[] = [ 134 | { sourceId: 'file1.txt', name: 'file1.txt', kind: 'file', size: 8196, lastModified: '2023-01-01T00:00:00Z' }, 135 | { sourceId: 'file2.txt', name: 'file2.txt', kind: 'file', size: 4096, lastModified: '2023-02-02T00:00:00Z' }, 136 | ] 137 | const dirSource: DirSource = { 138 | sourceId: 'test-source', 139 | sourceParts: [{ text: 'test-source', sourceId: 'test-source' }], 140 | kind: 'directory', 141 | prefix: '', 142 | listFiles: () => Promise.resolve(mockFiles), 143 | } 144 | const { getByPlaceholderText, getByText } = render() 145 | 146 | // Type a search query and hit enter 147 | const searchInput = getByPlaceholderText('Search...') as HTMLInputElement 148 | act(() => { 149 | fireEvent.keyUp(searchInput, { target: { value: 'file1' } }) 150 | }) 151 | 152 | await waitFor(() => { 153 | expect(getByText('file1.txt')).toBeDefined() 154 | }) 155 | 156 | act(() => { 157 | fireEvent.keyUp(searchInput, { key: 'Enter' }) 158 | }) 159 | 160 | expect(location.href).toBe('/files?key=file1.txt') 161 | }) 162 | 163 | it('jumps to search box when user types /', async () => { 164 | const dirSource: DirSource = { 165 | sourceId: 'test-source', 166 | sourceParts: [{ text: 'test-source', sourceId: 'test-source' }], 167 | kind: 'directory', 168 | prefix: '', 169 | listFiles: () => Promise.resolve([]), 170 | } 171 | const { getByPlaceholderText } = render() 172 | 173 | // Wait for component to settle 174 | await waitFor(() => { 175 | expect(fetch).toHaveBeenCalled() 176 | }) 177 | 178 | const searchInput = getByPlaceholderText('Search...') as HTMLInputElement 179 | 180 | // Typing / should focus the search box 181 | act(() => { 182 | fireEvent.keyDown(document.body, { key: '/' }) 183 | }) 184 | expect(document.activeElement).toBe(searchInput) 185 | 186 | // Typing inside the search box should work including / 187 | act(() => { 188 | fireEvent.keyUp(searchInput, { target: { value: 'file1/' } }) 189 | }) 190 | expect(searchInput.value).toBe('file1/') 191 | 192 | // Unfocus and re-focus should select all text in search box 193 | act(() => { 194 | searchInput.blur() 195 | }) 196 | expect(document.activeElement).not.toBe(searchInput) 197 | 198 | act(() => { 199 | fireEvent.keyDown(document.body, { key: '/' }) 200 | }) 201 | expect(document.activeElement).toBe(searchInput) 202 | expect(searchInput.selectionStart).toBe(0) 203 | expect(searchInput.selectionEnd).toBe(searchInput.value.length) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /src/components/Folder/Folder.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import type { DirSource, FileMetadata } from '../../lib/sources/types.js' 4 | import { cn, formatFileSize, getFileDate, getFileDateShort } from '../../lib/utils.js' 5 | import Breadcrumb from '../Breadcrumb/Breadcrumb.js' 6 | import Center from '../Center/Center.js' 7 | import Layout from '../Layout/Layout.js' 8 | import Spinner from '../Spinner/Spinner.js' 9 | import styles from './Folder.module.css' 10 | 11 | interface FolderProps { 12 | source: DirSource 13 | } 14 | 15 | /** 16 | * Folder browser page 17 | */ 18 | export default function Folder({ source }: FolderProps) { 19 | // State to hold file listing 20 | const [files, setFiles] = useState() 21 | const [error, setError] = useState() 22 | const [searchQuery, setSearchQuery] = useState('') 23 | const searchRef = useRef(null) 24 | const listRef = useRef(null) 25 | const { routes, customClass } = useConfig() 26 | 27 | // Fetch files on component mount 28 | useEffect(() => { 29 | source.listFiles() 30 | .then(setFiles) 31 | .catch((error: unknown) => { 32 | setFiles([]) 33 | setError(error instanceof Error ? error : new Error(`Failed to fetch files - ${error}`)) 34 | }) 35 | }, [source]) 36 | 37 | // File search 38 | const filtered = useMemo(() => { 39 | return files?.filter(file => file.name.toLowerCase().includes(searchQuery.toLowerCase())) 40 | }, [files, searchQuery]) 41 | 42 | useEffect(() => { 43 | const searchElement = searchRef.current 44 | function handleKeyup(e: KeyboardEvent) { 45 | const searchQuery = searchRef.current?.value ?? '' 46 | setSearchQuery(searchQuery) 47 | if (e.key === 'Escape') { 48 | // clear search 49 | if (searchRef.current) { 50 | searchRef.current.value = '' 51 | } 52 | setSearchQuery('') 53 | } else if (e.key === 'Enter') { 54 | // if there is only one result, view it 55 | if (filtered?.length === 1 && 0 in filtered) { 56 | const key = join(source.prefix, filtered[0].name) 57 | if (key.endsWith('/')) { 58 | // clear search because we're about to change folder 59 | if (searchRef.current) { 60 | searchRef.current.value = '' 61 | } 62 | setSearchQuery('') 63 | } 64 | location.href = `/files?key=${key}` 65 | } 66 | } else if (e.key === 'ArrowDown') { 67 | // move focus to first list item 68 | listRef.current?.querySelector('a')?.focus() 69 | } 70 | } 71 | searchElement?.addEventListener('keyup', handleKeyup) 72 | // Clean up event listener 73 | return () => searchElement?.removeEventListener('keyup', handleKeyup) 74 | }, [filtered, source.prefix]) 75 | 76 | // Jump to search box if user types '/' 77 | useEffect(() => { 78 | function handleKeydown(e: KeyboardEvent) { 79 | if (e.key === '/' && e.target === document.body) { 80 | e.preventDefault() 81 | searchRef.current?.focus() 82 | // select all text 83 | searchRef.current?.setSelectionRange(0, searchRef.current.value.length) 84 | } 85 | } 86 | document.addEventListener('keydown', handleKeydown) 87 | return () => { document.removeEventListener('keydown', handleKeydown) } 88 | }, []) 89 | 90 | return 91 | 92 | 93 | 94 | 95 | {filtered === undefined ? 96 |
: 97 | filtered.length === 0 ? 98 |
No files
: 99 | 118 | } 119 |
120 | } 121 | 122 | function join(prefix: string, file: string) { 123 | return prefix ? prefix + '/' + file : file 124 | } 125 | -------------------------------------------------------------------------------- /src/components/ImageView/ImageView.module.css: -------------------------------------------------------------------------------- 1 | .imageView { 2 | display: block; 3 | flex: 1; 4 | min-width: 0; 5 | object-fit: scale-down; 6 | background-image: linear-gradient(45deg, #ddd 25%, transparent 25%), 7 | linear-gradient(135deg, #ddd 25%, transparent 25%), 8 | linear-gradient(45deg, transparent 75%, #ddd 75%), 9 | linear-gradient(135deg, transparent 75%, #ddd 75%); 10 | background-size: 32px 32px; 11 | background-position: 0 0, 16px 0, 16px -16px, 0px 16px; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ImageView/ImageView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { strict as assert } from 'assert' 3 | import { describe, expect, it, vi } from 'vitest' 4 | import { getHyperparamSource } from '../../lib/sources/index.js' 5 | import ImageView from './ImageView.js' 6 | 7 | globalThis.fetch = vi.fn() 8 | 9 | describe('ImageView Component', () => { 10 | it('renders the image correctly', async () => { 11 | const body = new ArrayBuffer(8) 12 | vi.mocked(fetch).mockResolvedValueOnce({ 13 | arrayBuffer: () => Promise.resolve(body), 14 | headers: new Map([['content-length', body.byteLength]]), 15 | } as unknown as Response) 16 | 17 | const source = getHyperparamSource('test.png', { endpoint: 'http://localhost:3000' }) 18 | assert(source?.kind === 'file') 19 | 20 | const { findByRole, findByText } = render( 21 | 22 | ) 23 | 24 | // wait for asynchronous image loading 25 | expect(fetch).toHaveBeenCalled() 26 | const image = await findByRole('img') 27 | expect(image).toBeDefined() 28 | expect(image.getAttribute('src')).not.toBeNull() 29 | expect(image.getAttribute('alt')).toBe('test.png') 30 | await expect(findByText('8 b')).resolves.toBeDefined() 31 | }) 32 | 33 | // TODO: test error handling 34 | }) 35 | -------------------------------------------------------------------------------- /src/components/ImageView/ImageView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { FileSource } from '../../lib/sources/types.js' 4 | import { cn, contentTypes, parseFileSize } from '../../lib/utils.js' 5 | import ContentWrapper from '../ContentWrapper/ContentWrapper.js' 6 | import styles from './ImageView.module.css' 7 | 8 | interface ViewerProps { 9 | source: FileSource 10 | setError: (error: Error | undefined) => void 11 | } 12 | 13 | interface Content { 14 | dataUri: string 15 | fileSize?: number 16 | } 17 | 18 | /** 19 | * Image viewer component. 20 | */ 21 | export default function ImageView({ source, setError }: ViewerProps) { 22 | const [content, setContent] = useState() 23 | const [isLoading, setIsLoading] = useState(true) 24 | const { customClass } = useConfig() 25 | 26 | const { fileName, resolveUrl, requestInit } = source 27 | 28 | useEffect(() => { 29 | async function loadContent() { 30 | try { 31 | setIsLoading(true) 32 | const res = await fetch(resolveUrl, requestInit) 33 | if (res.status === 401) { 34 | const text = await res.text() 35 | setError(new Error(text)) 36 | setContent(undefined) 37 | return 38 | } 39 | const arrayBuffer = await res.arrayBuffer() 40 | // base64 encode and display image 41 | const b64 = arrayBufferToBase64(arrayBuffer) 42 | const dataUri = `data:${contentType(fileName)};base64,${b64}` 43 | const fileSize = parseFileSize(res.headers) 44 | setContent({ dataUri, fileSize }) 45 | setError(undefined) 46 | } catch (error) { 47 | setContent(undefined) 48 | setError(error as Error) 49 | } finally { 50 | setIsLoading(false) 51 | } 52 | } 53 | void loadContent() 54 | }, [fileName, resolveUrl, requestInit, setError]) 55 | 56 | return 57 | {content?.dataUri && {source.sourceId}} 61 | 62 | } 63 | 64 | /** 65 | * Convert an ArrayBuffer to a base64 string. 66 | * 67 | * @param buffer - the ArrayBuffer to convert 68 | * @returns base64 encoded string 69 | */ 70 | function arrayBufferToBase64(buffer: ArrayBuffer): string { 71 | let binary = '' 72 | const bytes = new Uint8Array(buffer) 73 | for (const byte of bytes) { 74 | binary += String.fromCharCode(byte) 75 | } 76 | return btoa(binary) 77 | } 78 | 79 | function contentType(filename: string): string { 80 | const ext = filename.split('.').pop() ?? '' 81 | return contentTypes[ext] ?? 'image/png' 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Json/Json.module.css: -------------------------------------------------------------------------------- 1 | .jsonView { 2 | background-color: #22222b; 3 | color: #d6d6d6; 4 | padding: 8px 8px 8px 20px; 5 | } 6 | 7 | .json { 8 | white-space: pre-wrap; 9 | } 10 | .json * { 11 | font-family: monospace; 12 | } 13 | 14 | .json ul { 15 | border-left: 1px solid #556; 16 | list-style-type: none; 17 | padding: 0; 18 | } 19 | 20 | .json li { 21 | margin: 4px 0 4px 20px; 22 | } 23 | 24 | .clickable { 25 | cursor: pointer; 26 | } 27 | 28 | .drill { 29 | color: #556; 30 | font-size: 10pt; 31 | margin-left: -12px; 32 | margin-right: 4px; 33 | padding: 2px 0; 34 | user-select: none; 35 | } 36 | .clickable:hover .drill { 37 | color: #778; 38 | } 39 | .key { 40 | color: #aad; 41 | font-weight: bold; 42 | } 43 | .array { 44 | color: #79e; 45 | } 46 | .number { 47 | color: #eea; 48 | } 49 | .object { 50 | color: #d8d; 51 | } 52 | .string { 53 | color: #eaa; 54 | } 55 | .other { 56 | color: #d6d6d6; 57 | } 58 | .comment { 59 | color: #ccd8; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Json/Json.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import type { ComponentProps } from 'react' 3 | import Json from './Json.js' 4 | 5 | const meta: Meta = { 6 | component: Json, 7 | } 8 | export default meta 9 | type Story = StoryObj; 10 | 11 | function render(args: ComponentProps) { 12 | return ( 13 |
14 | 15 |
16 | ) 17 | } 18 | 19 | export const Default: Story = { 20 | args: { 21 | json: { 22 | a: 1, 23 | b: 'hello', 24 | c: [1, 2, 3], 25 | d: { e: 4, f: 5 }, 26 | e: null, 27 | f: undefined, 28 | g: true, 29 | // h: 123456n, // commented because it breaks storybook... 30 | }, 31 | label: 'json', 32 | }, 33 | render, 34 | } 35 | 36 | export const Arrays: Story = { 37 | args: { 38 | json: { 39 | empty: [], 40 | numbers1: Array.from({ length: 1 }, (_, i) => i), 41 | numbers8: Array.from({ length: 8 }, (_, i) => i), 42 | numbers100: Array.from({ length: 100 }, (_, i) => i), 43 | strings2: Array.from({ length: 2 }, (_, i) => `hello ${i}`), 44 | strings8: Array.from({ length: 8 }, (_, i) => `hello ${i}`), 45 | strings100: Array.from({ length: 100 }, (_, i) => `hello ${i}`), 46 | misc: Array.from({ length: 8 }, (_, i) => i % 2 ? `hello ${i}` : i), 47 | misc2: Array.from({ length: 8 }, (_, i) => i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]), 48 | misc3: [1, 'hello', null, undefined], 49 | arrays100: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]), 50 | }, 51 | label: 'json', 52 | }, 53 | render, 54 | } 55 | 56 | export const Objects: Story = { 57 | args: { 58 | json: { 59 | empty: {}, 60 | numbers1: { k0: 1 }, 61 | numbers8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i])), 62 | numbers100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, i])), 63 | strings8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, `hello ${i}`])), 64 | strings100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, `hello ${i}`])), 65 | misc: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 2 ? `hello ${i}` : i])), 66 | misc2: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]])), 67 | misc3: { k0: 1, k1: 'a', k2: null, k3: undefined }, 68 | arrays100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])), 69 | }, 70 | label: 'json', 71 | }, 72 | render, 73 | } 74 | -------------------------------------------------------------------------------- /src/components/Json/Json.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react' 2 | import { describe, expect, it } from 'vitest' 3 | import Json from './Json.js' 4 | import { isPrimitive, shouldObjectCollapse } from './helpers.js' 5 | 6 | describe('Json Component', () => { 7 | it('renders primitive types correctly', () => { 8 | const { getByText } = render() 9 | expect(getByText('"test"')).toBeDefined() 10 | }) 11 | 12 | it('renders bigint correctly', () => { 13 | const { getByText } = render() 14 | expect(getByText('100')).toBeDefined() 15 | }) 16 | 17 | it('renders an array', () => { 18 | const { getByText } = render() 19 | expect(getByText('"foo"')).toBeDefined() 20 | expect(getByText('"bar"')).toBeDefined() 21 | }) 22 | 23 | it.for([ 24 | ['foo', 'bar'], 25 | [], 26 | [1, 2, 3], 27 | [1, 'foo', null], 28 | Array.from({ length: 101 }, (_, i) => i), 29 | ])('collapses any array', (array) => { 30 | const { queryByText } = render() 31 | expect(queryByText('▶')).toBeDefined() 32 | expect(queryByText('▼')).toBeNull() 33 | }) 34 | 35 | it.for([ 36 | ['foo', 'bar'], 37 | [], 38 | [1, 2, 3], 39 | [1, 'foo', null, undefined], 40 | ])('shows short arrays of primitive items, without trailing comment about length', (array) => { 41 | const { queryByText } = render() 42 | expect(queryByText('...')).toBeNull() 43 | expect(queryByText('length')).toBeNull() 44 | }) 45 | 46 | it.for([ 47 | [1, 'foo', [1, 2, 3]], 48 | Array.from({ length: 101 }, (_, i) => i), 49 | ])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => { 50 | const { queryByText } = render() 51 | expect(queryByText('...')).toBeDefined() 52 | expect(queryByText('length')).toBeDefined() 53 | }) 54 | 55 | it('renders an object', () => { 56 | const { getByText } = render() 57 | expect(getByText('key:')).toBeDefined() 58 | expect(getByText('"value"')).toBeDefined() 59 | }) 60 | 61 | it('renders nested objects', () => { 62 | const { getByText } = render() 63 | expect(getByText('obj:')).toBeDefined() 64 | expect(getByText('arr:')).toBeDefined() 65 | expect(getByText('314')).toBeDefined() 66 | expect(getByText('"42"')).toBeDefined() 67 | }) 68 | 69 | it.for([ 70 | { obj: [314, null] }, 71 | { obj: { nested: true } }, 72 | ])('expands short objects with non-primitive values', (obj) => { 73 | const { queryByText } = render() 74 | expect(queryByText('▼')).toBeDefined() 75 | }) 76 | 77 | it.for([ 78 | { obj: [314, null] }, 79 | { obj: { nested: true } }, 80 | ])('hides the content and append number of entries when objects with non-primitive values are collapsed', (obj) => { 81 | const { getByText, queryByText } = render() 82 | fireEvent.click(getByText('▼')) 83 | expect(queryByText('...')).toBeDefined() 84 | expect(queryByText('entries')).toBeDefined() 85 | }) 86 | 87 | it.for([ 88 | {}, 89 | { a: 1, b: 2 }, 90 | { a: 1, b: true, c: null, d: undefined }, 91 | Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])), 92 | ])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => { 93 | const { queryByText } = render() 94 | expect(queryByText('▶')).toBeDefined() 95 | expect(queryByText('▼')).toBeNull() 96 | }) 97 | 98 | it.for([ 99 | Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])), 100 | ])('hides the content and append number of entries when objects has many entries', (obj) => { 101 | const { queryByText } = render() 102 | expect(queryByText('...')).toBeDefined() 103 | expect(queryByText('entries')).toBeDefined() 104 | }) 105 | 106 | it('toggles array collapse state', () => { 107 | const longArray = Array.from({ length: 101 }, (_, i) => i) 108 | const { getByText, queryByText } = render() 109 | expect(getByText('...')).toBeDefined() 110 | fireEvent.click(getByText('▶')) 111 | expect(queryByText('...')).toBeNull() 112 | fireEvent.click(getByText('▼')) 113 | expect(getByText('...')).toBeDefined() 114 | }) 115 | 116 | it('toggles object collapse state', () => { 117 | const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])) 118 | const { getByText, queryByText } = render() 119 | expect(getByText('...')).toBeDefined() 120 | fireEvent.click(getByText('▶')) 121 | expect(queryByText('...')).toBeNull() 122 | fireEvent.click(getByText('▼')) 123 | expect(getByText('...')).toBeDefined() 124 | }) 125 | }) 126 | 127 | describe('isPrimitive', () => { 128 | it('returns true only for primitive types', () => { 129 | expect(isPrimitive('test')).toBe(true) 130 | expect(isPrimitive(42)).toBe(true) 131 | expect(isPrimitive(true)).toBe(true) 132 | expect(isPrimitive(1n)).toBe(true) 133 | expect(isPrimitive(null)).toBe(true) 134 | expect(isPrimitive(undefined)).toBe(true) 135 | expect(isPrimitive({})).toBe(false) 136 | expect(isPrimitive([])).toBe(false) 137 | }) 138 | }) 139 | 140 | describe('shouldObjectCollapse', () => { 141 | it('returns true for objects with all primitive values', () => { 142 | expect(shouldObjectCollapse({ a: 1, b: 'test' })).toBe(true) 143 | }) 144 | 145 | it('returns false for objects with non-primitive values', () => { 146 | expect(shouldObjectCollapse({ a: 1, b: {} })).toBe(false) 147 | }) 148 | 149 | it('returns true for large objects', () => { 150 | const largeObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, i])) 151 | expect(shouldObjectCollapse(largeObject)).toBe(true) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /src/components/Json/Json.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react' 2 | import styles from './Json.module.css' 3 | import { isPrimitive, shouldObjectCollapse } from './helpers.js' 4 | 5 | interface JsonProps { 6 | json: unknown 7 | label?: string 8 | } 9 | 10 | /** 11 | * JSON viewer component with collapsible objects and arrays. 12 | */ 13 | export default function Json({ json, label }: JsonProps): ReactNode { 14 | return
15 | } 16 | 17 | function JsonContent({ json, label }: JsonProps): ReactNode { 18 | let div 19 | if (Array.isArray(json)) { 20 | div = 21 | } else if (typeof json === 'object' && json !== null) { 22 | div = 23 | } else { 24 | // primitive 25 | const key = label ? {label}: : '' 26 | if (typeof json === 'string') { 27 | div = <>{key}{JSON.stringify(json)} 28 | } else if (typeof json === 'number') { 29 | div = <>{key}{JSON.stringify(json)} 30 | } else if (typeof json === 'bigint') { 31 | // it's not really json, but show it anyway 32 | div = <>{key}{json.toString()} 33 | } else if (json === undefined) { 34 | // it's not json 35 | div = <>{key}undefined 36 | } else { 37 | div = <>{key}{JSON.stringify(json)} 38 | } 39 | } 40 | return div 41 | } 42 | 43 | function CollapsedArray({ array }: {array: unknown[]}): ReactNode { 44 | // the character count is approximate, but it should be enough 45 | // to avoid showing too many entries 46 | const maxCharacterCount = 40 47 | const separator = ', ' 48 | 49 | const children: ReactNode[] = [] 50 | let suffix: string | undefined = undefined 51 | 52 | let characterCount = 0 53 | for (const [index, value] of array.entries()) { 54 | if (index > 0) { 55 | characterCount += separator.length 56 | children.push({separator}) 57 | } 58 | // should we continue? 59 | if (isPrimitive(value)) { 60 | const asString = typeof value === 'bigint' ? value.toString() : 61 | value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */: 62 | JSON.stringify(value) 63 | characterCount += asString.length 64 | if (characterCount < maxCharacterCount) { 65 | children.push() 66 | continue 67 | } 68 | } 69 | // no: it was the last entry 70 | children.push(...) 71 | suffix = ` length: ${array.length}` 72 | break 73 | } 74 | return ( 75 | <> 76 | {'['} 77 | {children} 78 | {']'} 79 | {suffix && {suffix}} 80 | 81 | ) 82 | } 83 | 84 | function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode { 85 | const [collapsed, setCollapsed] = useState(true) 86 | const key = label ? {label}: : '' 87 | if (collapsed) { 88 | return
{ setCollapsed(false) }}> 89 | {'\u25B6'} 90 | {key} 91 | 92 |
93 | } 94 | return <> 95 |
{ setCollapsed(true) }}> 96 | {'\u25BC'} 97 | {key} 98 | {'['} 99 |
100 |
    101 | {array.map((item, index) =>
  • {}
  • )} 102 |
103 |
{']'}
104 | 105 | } 106 | 107 | function CollapsedObject({ obj }: {obj: object}): ReactNode { 108 | // the character count is approximate, but it should be enough 109 | // to avoid showing too many entries 110 | const maxCharacterCount = 40 111 | const separator = ', ' 112 | const kvSeparator = ': ' 113 | 114 | const children: ReactNode[] = [] 115 | let suffix: string | undefined = undefined 116 | 117 | const entries = Object.entries(obj) 118 | let characterCount = 0 119 | for (const [index, [key, value]] of entries.entries()) { 120 | if (index > 0) { 121 | characterCount += separator.length 122 | children.push({separator}) 123 | } 124 | // should we continue? 125 | if (isPrimitive(value)) { 126 | const asString = typeof value === 'bigint' ? value.toString() : 127 | value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */: 128 | JSON.stringify(value) 129 | characterCount += key.length + kvSeparator.length + asString.length 130 | if (characterCount < maxCharacterCount) { 131 | children.push() 132 | continue 133 | } 134 | } 135 | // no: it was the last entry 136 | children.push(...) 137 | suffix = ` entries: ${entries.length}` 138 | break 139 | } 140 | return ( 141 | <> 142 | {'{'} 143 | {children} 144 | {'}'} 145 | {suffix && {suffix}} 146 | 147 | ) 148 | } 149 | 150 | function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode { 151 | const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj)) 152 | const key = label ? {label}: : '' 153 | if (collapsed) { 154 | return
{ setCollapsed(false) }}> 155 | {'\u25B6'} 156 | {key} 157 | 158 |
159 | } 160 | return <> 161 |
{ setCollapsed(true) }}> 162 | {'\u25BC'} 163 | {key} 164 | {'{'} 165 |
166 |
    167 | {Object.entries(obj).map(([key, value]) => 168 |
  • 169 | 170 |
  • 171 | )} 172 |
173 |
{'}'}
174 | 175 | } 176 | -------------------------------------------------------------------------------- /src/components/Json/helpers.ts: -------------------------------------------------------------------------------- 1 | export function isPrimitive(value: unknown): boolean { 2 | return ( 3 | value === undefined || 4 | value === null || 5 | !Array.isArray(value) && 6 | typeof value !== 'object' && 7 | typeof value !== 'function' 8 | ) 9 | } 10 | 11 | export function shouldObjectCollapse(obj: object): boolean { 12 | const values = Object.values(obj) 13 | if ( 14 | // if all the values are primitive 15 | values.every(value => isPrimitive(value)) 16 | // if the object has too many entries 17 | || values.length >= 100 18 | ) { 19 | return true 20 | } 21 | return false 22 | } 23 | -------------------------------------------------------------------------------- /src/components/JsonView/JsonView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from '@testing-library/react' 2 | import { describe, expect, it, vi } from 'vitest' 3 | import { FileSource } from '../../lib/sources/types.js' 4 | import JsonView from './JsonView.js' 5 | 6 | vi.mock('../../../src/lib/utils.js', async () => { 7 | const actual = await vi.importActual('../../../src/lib/utils.js') 8 | return { ...actual, asyncBufferFrom: vi.fn() } 9 | }) 10 | 11 | globalThis.fetch = vi.fn() 12 | 13 | describe('JsonView Component', () => { 14 | const encoder = new TextEncoder() 15 | 16 | it('renders json content as nested lists (if not collapsed)', async () => { 17 | const text = '{"key":["value"]}' 18 | const body = encoder.encode(text).buffer as ArrayBuffer 19 | const source: FileSource = { 20 | resolveUrl: 'testKey0', 21 | kind: 'file', 22 | fileName: 'testKey0', 23 | sourceId: 'testKey0', 24 | sourceParts: [], 25 | } 26 | vi.mocked(fetch).mockResolvedValueOnce({ 27 | status: 200, 28 | headers: new Headers({ 'Content-Length': body.byteLength.toString() }), 29 | text: () => Promise.resolve(text), 30 | } as Response) 31 | 32 | const { findByRole, findByText } = render( 33 | 34 | ) 35 | 36 | expect(fetch).toHaveBeenCalledWith('testKey0', undefined) 37 | // Wait for asynchronous JSON loading and parsing 38 | await expect(findByRole('list')).resolves.toBeDefined() 39 | await expect(findByText('key:')).resolves.toBeDefined() 40 | await expect(findByText('"value"')).resolves.toBeDefined() 41 | }) 42 | 43 | it('displays an error when the json content is too long', async () => { 44 | const source: FileSource = { 45 | sourceId: 'testKey1', 46 | sourceParts: [], 47 | kind: 'file', 48 | fileName: 'testKey1', 49 | resolveUrl: 'testKey1', 50 | } 51 | vi.mocked(fetch).mockResolvedValueOnce({ 52 | status: 200, 53 | headers: new Headers({ 'Content-Length': '8000001' }), 54 | text: () => Promise.resolve(''), 55 | } as Response) 56 | 57 | const setError = vi.fn() 58 | render() 59 | 60 | expect(fetch).toHaveBeenCalledWith('testKey1', undefined) 61 | await waitFor(() => { 62 | expect(setError).toHaveBeenCalledWith(expect.objectContaining({ 63 | message: 'File is too large to display', 64 | })) 65 | }) 66 | }) 67 | 68 | it('displays an error when the json content is invalid', async () => { 69 | const body = encoder.encode('INVALIDJSON').buffer as ArrayBuffer 70 | const source: FileSource = { 71 | resolveUrl: 'testKey2', 72 | kind: 'file', 73 | fileName: 'testKey2', 74 | sourceId: 'testKey2', 75 | sourceParts: [], 76 | } 77 | vi.mocked(fetch).mockResolvedValueOnce({ 78 | status: 200, 79 | headers: new Headers({ 'Content-Length': body.byteLength.toString() }), 80 | text: () => Promise.resolve('INVALIDJSON'), 81 | } as Response) 82 | 83 | const setError = vi.fn() 84 | render() 85 | 86 | expect(fetch).toHaveBeenCalledWith('testKey2', undefined) 87 | await waitFor(() => { 88 | expect(setError).toHaveBeenCalledWith(expect.objectContaining({ 89 | message: expect.stringContaining('Unexpected token') as string, 90 | })) 91 | }) 92 | }) 93 | 94 | it('displays an error when unauthorized', async () => { 95 | const source: FileSource = { 96 | resolveUrl: 'testKey3', 97 | kind: 'file', 98 | fileName: 'testKey3', 99 | sourceId: 'testKey3', 100 | sourceParts: [], 101 | } 102 | vi.mocked(fetch).mockResolvedValueOnce({ 103 | status: 401, 104 | text: () => Promise.resolve('Unauthorized'), 105 | } as Response) 106 | 107 | const setError = vi.fn() 108 | render() 109 | 110 | expect(fetch).toHaveBeenCalledWith('testKey3', undefined) 111 | await waitFor(() => { 112 | expect(setError).toHaveBeenCalledWith(expect.objectContaining({ 113 | message: 'Unauthorized', 114 | })) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /src/components/JsonView/JsonView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import type { FileSource } from '../../lib/sources/types.js' 3 | import { parseFileSize } from '../../lib/utils.js' 4 | import Center from '../Center/Center.js' 5 | import ContentWrapper, { TextContent } from '../ContentWrapper/ContentWrapper.js' 6 | import Json from '../Json/Json.js' 7 | import styles from '../Json/Json.module.css' 8 | 9 | interface ViewerProps { 10 | source: FileSource 11 | setError: (error: Error | undefined) => void 12 | } 13 | 14 | const largeFileSize = 8_000_000 // 8 mb 15 | 16 | /** 17 | * JSON viewer component. 18 | */ 19 | export default function JsonView({ source, setError }: ViewerProps) { 20 | const [content, setContent] = useState() 21 | const [json, setJson] = useState() 22 | const [isLoading, setIsLoading] = useState(true) 23 | 24 | const { resolveUrl, requestInit } = source 25 | 26 | // Load json content 27 | useEffect(() => { 28 | async function loadContent() { 29 | try { 30 | setIsLoading(true) 31 | const res = await fetch(resolveUrl, requestInit) 32 | const futureText = res.text() 33 | if (res.status === 401) { 34 | const text = await futureText 35 | setError(new Error(text)) 36 | setContent(undefined) 37 | return 38 | } 39 | const fileSize = parseFileSize(res.headers) ?? (await futureText).length 40 | if (fileSize > largeFileSize) { 41 | setError(new Error('File is too large to display')) 42 | setContent(undefined) 43 | return 44 | } 45 | const text = await futureText 46 | setError(undefined) 47 | setContent({ text, fileSize }) 48 | setJson(JSON.parse(text)) 49 | } catch (error) { 50 | // TODO: show plain text in error case 51 | setError(error as Error) 52 | } finally { 53 | setIsLoading(false) 54 | } 55 | } 56 | void loadContent() 57 | }, [resolveUrl, requestInit, setError]) 58 | 59 | const headers = content?.text === undefined && Loading... 60 | 61 | const isLarge = content?.fileSize && content.fileSize > 1024 * 1024 62 | 63 | // If json failed to parse, show the text instead 64 | const showFallbackText = content?.text !== undefined && json === undefined 65 | 66 | return 67 | {isLarge ? 68 |
File is too large to display
69 | : 70 | <> 71 | {!showFallbackText && 72 | 73 | } 74 | {showFallbackText && 75 | {content.text} 76 | } 77 | 78 | } 79 |
80 | } 81 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .layout { 2 | /* layout */ 3 | display: flex; 4 | height: 100vh; 5 | max-width: 100vw; 6 | 7 | /* content area */ 8 | main { 9 | min-width: 0; 10 | height: 100vh; 11 | display: flex; 12 | flex-direction: column; 13 | flex: 1; 14 | 15 | & > div:first-child { 16 | display: flex; 17 | flex-direction: column; 18 | flex: 1; 19 | height: 100vh; 20 | padding: 0; 21 | /* no outer scrollbars */ 22 | overflow: hidden; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { describe, expect, it } from 'vitest' 3 | import Layout from './Layout.js' 4 | 5 | describe('Layout Component', () => { 6 | it('renders children', () => { 7 | const { getByText } = render(Test Content) 8 | expect(getByText('Test Content')).toBeDefined() 9 | expect(document.title).toBe('hyperparam') 10 | }) 11 | 12 | it('renders title', () => { 13 | render(Test Content) 14 | expect(document.title).toBe('Test Title - hyperparam') 15 | }) 16 | 17 | it('displays progress bar', () => { 18 | const { getByRole } = render(Test Content) 19 | expect(getByRole('progressbar')).toBeDefined() 20 | }) 21 | 22 | it('displays error message', () => { 23 | const testError = new Error('Test Error') 24 | const { getByText } = render(Test Content) 25 | expect(getByText('Error: Test Error')).toBeDefined() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { cn } from '../../lib/utils.js' 4 | import ErrorBar from '../ErrorBar/ErrorBar.js' 5 | import ProgressBar from '../ProgressBar/ProgressBar.js' 6 | import SideBar from '../SideBar/SideBar.js' 7 | import Welcome from '../Welcome/Welcome.js' 8 | import styles from './Layout.module.css' 9 | 10 | interface LayoutProps { 11 | children: ReactNode 12 | progress?: number 13 | error?: Error 14 | title?: string 15 | } 16 | 17 | /** 18 | * Layout for shared UI. 19 | * Content div style can be overridden by className prop. 20 | * 21 | * @param props 22 | * @param props.children - content to display inside the layout 23 | * @param props.progress - progress bar value 24 | * @param props.error - error message to display 25 | * @param props.title - page title 26 | */ 27 | export default function Layout({ children, progress, error, title }: LayoutProps) { 28 | const [showWelcome, setShowWelcome] = useState(false) 29 | const { customClass } = useConfig() 30 | 31 | // Check localStorage on mount to see if the user has seen the welcome popup 32 | useEffect(() => { 33 | const dismissed = localStorage.getItem('welcome:dismissed') === 'true' 34 | setShowWelcome(!dismissed) 35 | }, []) 36 | 37 | // Handle closing the welcome popup 38 | function handleCloseWelcome() { 39 | setShowWelcome(false) 40 | localStorage.setItem('welcome:dismissed', 'true') 41 | } 42 | 43 | // Update title 44 | useEffect(() => { 45 | document.title = title ? `${title} - hyperparam` : 'hyperparam' 46 | }, [title]) 47 | 48 | return
49 | 50 |
51 |
52 | {children} 53 |
54 | 55 |
56 | {progress !== undefined && progress < 1 && } 57 | {showWelcome && } 58 |
59 | } 60 | -------------------------------------------------------------------------------- /src/components/MarkdownView/MarkdownView.module.css: -------------------------------------------------------------------------------- 1 | /* markdownView */ 2 | .markdownView { 3 | background-color: #222226; 4 | color: #ddd; 5 | flex: 1; 6 | padding: 8px 20px; 7 | white-space: pre-wrap; 8 | overflow-y: auto; 9 | } 10 | .markdownView a { 11 | color: #cdf; 12 | } 13 | .markdownView p { 14 | margin-block: 1em; 15 | } 16 | .markdownView pre { 17 | background-color: #34343a; 18 | border-left: #446 solid 5px; 19 | margin: 0; 20 | padding: 12px 14px; 21 | white-space: pre-wrap; 22 | } 23 | .markdownView h1 { 24 | font-size: 32px; 25 | font-weight: 500; 26 | margin-bottom: 8px; 27 | } 28 | .markdownView h2 { 29 | font-weight: 500; 30 | margin-bottom: 8px; 31 | margin-top: 16px; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/MarkdownView/MarkdownView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { strict as assert } from 'assert' 3 | import { describe, expect, it, vi } from 'vitest' 4 | import { getHyperparamSource } from '../../lib/sources/index.js' 5 | import MarkdownView from './MarkdownView.js' 6 | 7 | globalThis.fetch = vi.fn() 8 | 9 | describe('MarkdownView Component', () => { 10 | it('renders markdown correctly', async () => { 11 | const text = '# Markdown\n\nThis is a test of the markdown viewer.' 12 | vi.mocked(fetch).mockResolvedValueOnce({ 13 | text: () => Promise.resolve(text), 14 | headers: new Map([['content-length', text.length]]), 15 | } as unknown as Response) 16 | 17 | const source = getHyperparamSource('test.md', { endpoint: 'http://localhost:3000' }) 18 | assert(source?.kind === 'file') 19 | 20 | const { findByText } = render( 21 | 22 | ) 23 | 24 | expect(fetch).toHaveBeenCalled() 25 | await expect(findByText('Markdown')).resolves.toBeDefined() 26 | await expect(findByText('This is a test of the markdown viewer.')).resolves.toBeDefined() 27 | await expect(findByText('50 b')).resolves.toBeDefined() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/MarkdownView/MarkdownView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import type { FileSource } from '../../lib/sources/types.js' 4 | import { cn, parseFileSize } from '../../lib/utils.js' 5 | import ContentWrapper, { TextContent } from '../ContentWrapper/ContentWrapper.js' 6 | import Markdown from '../Markdown/Markdown.js' 7 | import styles from './MarkdownView.module.css' 8 | 9 | interface ViewerProps { 10 | source: FileSource 11 | setError: (error: Error | undefined) => void 12 | } 13 | 14 | /** 15 | * Markdown viewer component. 16 | */ 17 | export default function MarkdownView({ source, setError }: ViewerProps) { 18 | const [content, setContent] = useState() 19 | const [isLoading, setIsLoading] = useState(true) 20 | const { customClass } = useConfig() 21 | 22 | const { resolveUrl, requestInit } = source 23 | 24 | // Load markdown content 25 | useEffect(() => { 26 | async function loadContent() { 27 | try { 28 | setIsLoading(true) 29 | const res = await fetch(resolveUrl, requestInit) 30 | const text = await res.text() 31 | const fileSize = parseFileSize(res.headers) ?? text.length 32 | if (res.status === 401) { 33 | setError(new Error(text)) 34 | setContent(undefined) 35 | return 36 | } 37 | setError(undefined) 38 | setContent({ text, fileSize }) 39 | } catch (error) { 40 | setError(error as Error) 41 | setContent(undefined) 42 | } finally { 43 | setIsLoading(false) 44 | } 45 | } 46 | void loadContent() 47 | }, [resolveUrl, requestInit, setError]) 48 | 49 | return 50 | 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Page/Page.tsx: -------------------------------------------------------------------------------- 1 | import { Source } from '../../lib/sources/types.js' 2 | import Cell from '../Cell/Cell.js' 3 | import File from '../File/File.js' 4 | import Folder from '../Folder/Folder.js' 5 | 6 | export interface Navigation { 7 | col?: number 8 | row?: number 9 | } 10 | 11 | export interface PageProps { 12 | source: Source, 13 | navigation?: Navigation, 14 | } 15 | 16 | export default function Page({ source, navigation }: PageProps) { 17 | if (source.kind === 'directory') { 18 | return 19 | } 20 | if (navigation?.row !== undefined && navigation.col !== undefined) { 21 | // cell view 22 | return 23 | } else { 24 | // file view 25 | return 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ParquetView/ParquetView.module.css: -------------------------------------------------------------------------------- 1 | /* table overrides */ 2 | .hightable { 3 | /* table corner */ 4 | thead td:first-child { 5 | background: url("https://hyperparam.app/assets/table/hyperparam.svg") 6 | #f9f4ff no-repeat center 6px; 7 | } 8 | /* cells */ 9 | tbody td { 10 | cursor: pointer; 11 | } 12 | /* row numbers */ 13 | tbody tr:hover [role="rowheader"] { 14 | background-color: #ccd; 15 | border-right: 1px solid #ccc; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ParquetView/ParquetView.tsx: -------------------------------------------------------------------------------- 1 | import HighTable, { DataFrame, rowCache } from 'hightable' 2 | import 'hightable/src/HighTable.css' 3 | import { asyncBufferFromUrl, parquetMetadataAsync } from 'hyparquet' 4 | import React, { useCallback, useEffect, useState } from 'react' 5 | import { useConfig } from '../../hooks/useConfig.js' 6 | import { appendSearchParams } from '../../lib/routes.js' 7 | import { FileSource } from '../../lib/sources/types.js' 8 | import { parquetDataFrame } from '../../lib/tableProvider.js' 9 | import { cn } from '../../lib/utils.js' 10 | import CellPanel from '../CellPanel/CellPanel.js' 11 | import ContentWrapper, { ContentSize } from '../ContentWrapper/ContentWrapper.js' 12 | import SlidePanel from '../SlidePanel/SlidePanel.js' 13 | import styles from './ParquetView.module.css' 14 | 15 | interface ViewerProps { 16 | source: FileSource 17 | setProgress: (progress: number | undefined) => void 18 | setError: (error: Error | undefined) => void 19 | } 20 | 21 | interface Content extends ContentSize { 22 | dataframe: DataFrame 23 | } 24 | 25 | /** 26 | * Parquet file viewer 27 | */ 28 | export default function ParquetView({ source, setProgress, setError }: ViewerProps) { 29 | const [isLoading, setIsLoading] = useState(true) 30 | const [content, setContent] = useState() 31 | const [cell, setCell] = useState<{ row: number, col: number } | undefined>() 32 | const { customClass, routes } = useConfig() 33 | 34 | useEffect(() => { 35 | async function loadParquetDataFrame() { 36 | try { 37 | setIsLoading(true) 38 | setProgress(0.33) 39 | const { resolveUrl, requestInit } = source 40 | const asyncBuffer = await asyncBufferFromUrl({ url: resolveUrl, requestInit }) 41 | const from = { url: resolveUrl, byteLength: asyncBuffer.byteLength, requestInit } 42 | setProgress(0.66) 43 | const metadata = await parquetMetadataAsync(asyncBuffer) 44 | let dataframe = parquetDataFrame(from, metadata) 45 | dataframe = rowCache(dataframe) 46 | const fileSize = asyncBuffer.byteLength 47 | setContent({ dataframe, fileSize }) 48 | } catch (error) { 49 | setError(error as Error) 50 | } finally { 51 | setIsLoading(false) 52 | setProgress(1) 53 | } 54 | } 55 | void loadParquetDataFrame() 56 | }, [setError, setProgress, source]) 57 | 58 | // Close cell view on escape key 59 | useEffect(() => { 60 | if (!cell) return 61 | 62 | function handleKeyDown(e: KeyboardEvent) { 63 | if (e.key === 'Escape') { 64 | setCell(undefined) 65 | } 66 | } 67 | 68 | window.addEventListener('keydown', handleKeyDown) 69 | return () => { window.removeEventListener('keydown', handleKeyDown) } 70 | }, [cell]) 71 | 72 | const { sourceId } = source 73 | const getCellRouteUrl = useCallback(({ col, row }: {col: number, row: number}) => { 74 | const url = routes?.getCellRouteUrl?.({ sourceId, col, row }) 75 | if (url) { 76 | return url 77 | } 78 | return appendSearchParams({ col: col.toString(), row: row.toString() }) 79 | }, [routes, sourceId]) 80 | 81 | const toggleCell = useCallback((col: number, row: number) => { 82 | setCell(cell => { 83 | if (cell?.col === col && cell.row === row) { 84 | return undefined 85 | } 86 | return { row, col } 87 | }) 88 | }, []) 89 | const onDoubleClickCell = useCallback((_event: React.MouseEvent, col: number, row: number) => { 90 | toggleCell(col, row) 91 | }, [toggleCell]) 92 | const onKeyDownCell = useCallback((event: React.KeyboardEvent, col: number, row: number) => { 93 | if (event.key === 'Enter') { 94 | event.preventDefault() 95 | toggleCell(col, row) 96 | } 97 | }, [toggleCell]) 98 | const onMouseDownCell = useCallback((event: React.MouseEvent, col: number, row: number) => { 99 | if (event.button === 1) { 100 | // Middle click open in new tab 101 | event.preventDefault() 102 | window.open(getCellRouteUrl({ row, col }), '_blank') 103 | } 104 | }, [getCellRouteUrl]) 105 | 106 | const headers = {content?.dataframe.numRows.toLocaleString() ?? '...'} rows 107 | 108 | const mainContent = 109 | {content?.dataframe && } 118 | 119 | 120 | let panelContent 121 | if (content?.dataframe && cell) { 122 | panelContent = 123 | { setCell(undefined) }} 127 | row={cell.row} 128 | setError={setError} 129 | setProgress={setProgress} 130 | /> 131 | } 132 | 133 | return ( 134 | 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /src/components/ProgressBar/ProgressBar.module.css: -------------------------------------------------------------------------------- 1 | /* progress bar */ 2 | .progressBar { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | height: 2px; 8 | z-index: 1000; 9 | transition: width 0.3s; 10 | 11 | /* Shimmer effect overlay */ 12 | background-image: linear-gradient(to right, #ddd 0%, #cbb 50%, #ddd 100%); 13 | background-size: 1000px; 14 | animation: shimmer 4s infinite linear; 15 | 16 | @keyframes shimmer { 17 | 0% { 18 | background-position: -1000px; 19 | } 20 | 100% { 21 | background-position: 1000px; 22 | } 23 | } 24 | 25 | & > [role="presentation"] { 26 | height: 100%; 27 | background-color: #3a4; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ProgressBar/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from '../../hooks/useConfig' 2 | import { cn } from '../../lib/utils.js' 3 | import VisuallyHidden from '../VisuallyHidden/VisuallyHidden.js' 4 | import styles from './ProgressBar.module.css' 5 | 6 | export default function ProgressBar({ value }: {value: number}) { 7 | const { customClass } = useConfig() 8 | if (value < 0 || value > 1) { 9 | throw new Error('ProgressBar value must be between 0 and 1') 10 | } 11 | const roundedValue = Math.round(value * 100) / 100 12 | const percentage = roundedValue.toLocaleString('en-US', { style: 'percent' }) 13 | return ( 14 |
21 | {percentage} 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/SideBar/SideBar.module.css: -------------------------------------------------------------------------------- 1 | /* sidebar */ 2 | .sideBar { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | width: 48px; 7 | height: 100vh; 8 | } 9 | .sideBar > div { 10 | background-image: linear-gradient(to bottom, #f2f2f2, #e4e4e4); 11 | box-shadow: 0 0 6px rgba(10, 10, 10, 0.4); 12 | height: 100vh; 13 | position: absolute; 14 | width: 48px; 15 | z-index: 30; 16 | } 17 | 18 | /* brand logo */ 19 | .brand { 20 | align-items: center; 21 | color: #222; 22 | display: flex; 23 | filter: drop-shadow(0 0 2px #bbb); 24 | font-family: "Century Gothic", "Helvetica Neue", Helvetica, Arial, sans-serif; 25 | font-size: 1.1em; 26 | font-weight: bold; 27 | text-orientation: mixed; 28 | letter-spacing: 0.3px; 29 | padding: 10px 12px; 30 | user-select: none; 31 | writing-mode: vertical-rl; 32 | } 33 | .brand:hover { 34 | color: #222; 35 | filter: drop-shadow(0 0 2px #afa6b9); 36 | text-decoration: none; 37 | } 38 | .brand::before { 39 | content: ""; 40 | background: url("../../assets/logo.svg") no-repeat 0 center; 41 | background-size: 26px; 42 | height: 26px; 43 | width: 26px; 44 | margin-bottom: 10px; 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from '../../hooks/useConfig.js' 2 | import { cn } from '../../lib/utils.js' 3 | import styles from './SideBar.module.css' 4 | 5 | export default function SideBar() { 6 | const { customClass } = useConfig() 7 | return 12 | } 13 | -------------------------------------------------------------------------------- /src/components/SlideCloseButton/SlideCloseButton.module.css: -------------------------------------------------------------------------------- 1 | /* slide panel close button */ 2 | .slideClose, 3 | .slideClose:active, 4 | .slideClose:focus, 5 | .slideClose:hover { 6 | background: none; 7 | border: none; 8 | color: #888; 9 | font-size: 16px; 10 | height: 24px; 11 | margin-right: auto; 12 | outline: none; 13 | padding: 0; 14 | transition: color 0.3s; 15 | } 16 | .slideClose::before { 17 | content: "\27E9\27E9"; 18 | } 19 | .slideClose:hover { 20 | color: #000; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/SlideCloseButton/SlideCloseButton.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from 'react' 2 | import { useConfig } from '../../hooks/useConfig.js' 3 | import { cn } from '../../lib/utils.js' 4 | import styles from './SlideCloseButton.module.css' 5 | 6 | export default function SlideCloseButton({ onClick }: { onClick: MouseEventHandler | undefined }) { 7 | const { customClass } = useConfig() 8 | return ( 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/SlidePanel/SlidePanel.module.css: -------------------------------------------------------------------------------- 1 | .slidePanel { 2 | display: flex; 3 | flex: 1; 4 | min-height: 0; 5 | 6 | & > article { 7 | /* main content */ 8 | display: flex; 9 | flex-direction: column; 10 | flex: 1; 11 | min-height: 0; 12 | overflow: auto; 13 | } 14 | 15 | /* resizer separator */ 16 | & > [role="separator"] { 17 | width: 5px; 18 | cursor: col-resize; 19 | background-color: #bfbbbb; 20 | transition: background-color 0.2s; 21 | user-select: none; 22 | 23 | &:hover { 24 | background-color: #9f9999; 25 | } 26 | } 27 | 28 | /* panel content */ 29 | & > aside { 30 | display: flex; 31 | flex-direction: column; 32 | width: 0; 33 | transition: width 0.2s; 34 | 35 | &[data-resizing="true"] { 36 | transition: none; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SlidePanel/SlidePanel.test.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { act, fireEvent, render } from '@testing-library/react' 3 | import { beforeEach, describe, expect, it, vi } from 'vitest' 4 | import { ConfigProvider } from '../../hooks/useConfig.js' 5 | import SlidePanel from './SlidePanel.js' 6 | 7 | describe('SlidePanel', () => { 8 | // Minimal localStorage mock 9 | const localStorageMock = (() => { 10 | let store: Record = {} 11 | return { 12 | getItem: (key: string) => store[key] ?? null, 13 | setItem: (key: string, value: string) => { store[key] = value }, 14 | clear: () => { store = {} }, 15 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 16 | removeItem: (key: string) => { delete store[key] }, 17 | } 18 | })() 19 | 20 | beforeEach(() => { 21 | vi.stubGlobal('localStorage', localStorageMock) 22 | localStorage.clear() 23 | }) 24 | 25 | it('renders main and panel content', () => { 26 | const { getByText } = render( 27 | Main
} 29 | panelContent={
Panel
} 30 | isPanelOpen 31 | /> 32 | ) 33 | expect(getByText('Main')).toBeDefined() 34 | expect(getByText('Panel')).toBeDefined() 35 | }) 36 | 37 | it('does not render the resizer if panel is closed', () => { 38 | const { queryByRole } = render( 39 | Main} 41 | panelContent={
Panel
} 42 | isPanelOpen={false} 43 | /> 44 | ) 45 | expect(queryByRole('separator')).toBeNull() 46 | }) 47 | 48 | it('uses default width of 400 when localStorage is empty', () => { 49 | const { getByRole } = render( 50 | Main} 52 | panelContent={
Panel
} 53 | isPanelOpen 54 | /> 55 | ) 56 | // Panel is an