Files
orca/src/main/ipc/markdown-documents.ts
T
NeilandNeil 130b2ef425 feat(documents): open CSV and TSV files from the OS
Extend existing OS document associations and delivery to CSV/TSV, preserving restoration and authorization.

Co-authored-by: Neil <neil@stably.ai>
2026-10-03 00:51:13 -07:00

227 lines
7.4 KiB
TypeScript

import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
import { parseWslPath } from '../wsl'
import {
isRipgrepMissingCwdExit,
ripgrepMissingCwdError
} from '../../shared/ripgrep-process-availability'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
export function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
const normalizedPath = relativePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function hasParentTraversalSegment(relativePath: string): boolean {
return relativePath.split(/[\\/]+/).includes('..')
}
function rootRelativePath(rootPath: string, filePath: string): string | null {
const resolvedRoot = resolve(rootPath)
const resolvedFile = resolve(filePath)
const relativePath = relative(resolvedRoot, resolvedFile)
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
return null
}
return normalizeRelativePath(relativePath)
}
export function fileDocumentFromFilePath(
rootPath: string,
filePath: string,
options: { outsideRootRelativePath?: 'basename' | 'relative' } = {}
): FileDocument {
const basename = pathBasename(filePath)
const extension = extname(basename)
const relativePath =
rootRelativePath(rootPath, filePath) ??
(options.outsideRootRelativePath === 'basename'
? basename
: normalizeRelativePath(relative(rootPath, filePath)))
return {
filePath,
relativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export const markdownDocumentFromFilePath = fileDocumentFromFilePath
export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
const normalizedRelativePath = normalizeRelativePath(relativePath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
if (!isMarkdownDocumentName(basename)) {
return null
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export function markdownDocumentsFromRelativePaths(
rootPath: string,
relativePaths: string[]
): MarkdownDocument[] {
return relativePaths
.map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
.filter((document): document is MarkdownDocument => document !== null)
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
const MARKDOWN_LISTING_TIMEOUT_MS = 15_000
const MAX_MARKDOWN_PATH_BYTES = 1024 * 1024
export async function listMarkdownDocuments(
rootPath: string,
options: { wslDistro?: string } = {}
): Promise<MarkdownDocument[]> {
const child = spawnBundledRipgrep(
[
'--files',
'--hidden',
'--no-ignore',
'--no-config',
'--null',
'--path-separator',
'/',
// Directory-only globs preserve hidden Markdown files without traversing hidden folders.
'--glob',
'**',
'--glob',
'!**/.*/',
'--glob',
'**/.github/',
'--glob',
'!**/node_modules/',
'.'
],
{
cwd: rootPath,
wslDistro: options.wslDistro,
wslDistroForOutput: parseWslPath(rootPath)?.distro ?? options.wslDistro,
stdio: ['ignore', 'pipe', 'pipe']
}
)
return new Promise((resolveListing, reject) => {
const documents: MarkdownDocument[] = []
let carry = ''
let stderr = ''
let settled = false
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
child.stdout?.off('data', onData)
child.stderr?.off('data', onStderr)
child.stdout?.off('error', onError)
child.stderr?.off('error', onError)
child.off('close', onClose)
child.off('error', onError)
// A spawn or pipe error can arrive after a timeout has already settled the listing.
child.on('error', ignoreLateError)
child.stdout?.on('error', ignoreLateError)
child.stderr?.on('error', ignoreLateError)
carry = ''
if (error) {
if (child.pid !== undefined) {
try {
child.kill('SIGKILL')
} catch {
// The process may have exited before the timeout or stream error arrived.
}
}
documents.length = 0
child.stdout?.resume()
child.stderr?.resume()
reject(error)
} else {
resolveListing(documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)))
}
}
const onError = (error: Error): void => finish(error)
const onStderr = (chunk: string): void => {
stderr = (stderr + chunk).slice(0, 4096)
}
const onData = (chunk: string): void => {
carry += chunk
let start = 0
let end: number
while ((end = carry.indexOf('\0', start)) !== -1) {
const path = carry.slice(start, end)
if (Buffer.byteLength(path) > MAX_MARKDOWN_PATH_BYTES) {
finish(new Error('Markdown document path exceeds the listing limit'))
return
}
if (!path.startsWith('./') || path.split('/').includes('..')) {
finish(new Error('Invalid path in Markdown document listing'))
return
}
if (isMarkdownDocumentName(path)) {
documents.push(markdownDocumentFromFilePath(rootPath, join(rootPath, path.slice(2))))
}
start = end + 1
}
carry = carry.slice(start)
if (Buffer.byteLength(carry) > MAX_MARKDOWN_PATH_BYTES) {
finish(new Error('Markdown document path exceeds the listing limit'))
}
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (isRipgrepMissingCwdExit(code)) {
finish(ripgrepMissingCwdError(rootPath))
} else if (signal || (code !== 0 && code !== 1)) {
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
} else if (carry) {
finish(new Error('Incomplete path in Markdown document listing'))
} else {
finish()
}
}
const timer = setTimeout(
() => finish(new Error('Markdown document listing timed out')),
MARKDOWN_LISTING_TIMEOUT_MS
)
timer.unref?.()
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', onData)
child.stderr?.on('data', onStderr)
child.stdout?.on('error', onError)
child.stderr?.on('error', onError)
child.once('error', onError)
child.once('close', onClose)
})
}
function ignoreLateError(): void {}