diff --git a/packages/desktop/src/features/explorer/tree.test.ts b/packages/desktop/src/features/explorer/tree.test.ts index 8dc9352..41e2e71 100644 --- a/packages/desktop/src/features/explorer/tree.test.ts +++ b/packages/desktop/src/features/explorer/tree.test.ts @@ -25,6 +25,38 @@ describe('buildExplorerTree', () => { 'src/Beta.http' ]); }); + + it('supports custom file ordering for non-directory nodes', () => { + const tree = buildExplorerTree( + [ + { path: 'src/request.ts' }, + { path: 'src/api.http' }, + { path: 'src/events.rest' }, + { path: 'src/worker.ts' } + ], + { + compareFiles: (a, b) => { + const aIsRequestFile = a.name.endsWith('.http') || a.name.endsWith('.rest'); + const bIsRequestFile = b.name.endsWith('.http') || b.name.endsWith('.rest'); + if (aIsRequestFile && !bIsRequestFile) { + return -1; + } + if (!aIsRequestFile && bIsRequestFile) { + return 1; + } + return 0; + } + } + ); + + const srcNode = tree.find((node) => node.path === 'src'); + expect(srcNode?.children?.map((node) => node.path)).toEqual([ + 'src/api.http', + 'src/events.rest', + 'src/request.ts', + 'src/worker.ts' + ]); + }); }); describe('flattenExplorerTree', () => { diff --git a/packages/desktop/src/features/explorer/tree.ts b/packages/desktop/src/features/explorer/tree.ts index 796e38a..383f34e 100644 --- a/packages/desktop/src/features/explorer/tree.ts +++ b/packages/desktop/src/features/explorer/tree.ts @@ -1,172 +1,8 @@ -import type { - ExplorerExpandedState, - ExplorerFileEntry, - ExplorerFlatNode, - ExplorerNode -} from './types'; -import { normalizeRelativePath } from './utils/path'; - -type MutableNode = ExplorerNode & { - childMap?: Map; -}; - -function compareNodes(a: ExplorerNode, b: ExplorerNode): number { - if (a.isDir && !b.isDir) return -1; - if (!a.isDir && b.isDir) return 1; - return a.name.localeCompare(b.name, undefined, { - sensitivity: 'base', - numeric: true - }); -} - -function toExplorerNodes(map: Map): ExplorerNode[] { - return Array.from(map.values()) - .map((node): ExplorerNode => { - const children = node.childMap ? toExplorerNodes(node.childMap) : undefined; - return { - name: node.name, - path: node.path, - isDir: node.isDir, - depth: node.depth, - children, - requestCount: node.requestCount - }; - }) - .sort(compareNodes); -} - -export function buildExplorerTree(files: ExplorerFileEntry[]): ExplorerNode[] { - const rootMap = new Map(); - - for (const file of files) { - const normalizedPath = normalizeRelativePath(file.path); - if (!normalizedPath) continue; - - const parts = normalizedPath.split('/'); - let currentPath = ''; - let cursorMap = rootMap; - - for (let index = 0; index < parts.length; index += 1) { - const part = parts[index]; - if (!part) continue; - - currentPath = currentPath ? `${currentPath}/${part}` : part; - const isDir = index < parts.length - 1; - let node = cursorMap.get(part); - - if (!node) { - node = { - name: part, - path: currentPath, - isDir, - depth: index, - children: isDir ? [] : undefined, - requestCount: isDir ? undefined : file.requestCount, - childMap: isDir ? new Map() : undefined - }; - cursorMap.set(part, node); - } else { - if (isDir && !node.isDir) { - node.isDir = true; - node.requestCount = undefined; - node.children = []; - node.childMap = new Map(); - } - - if (!isDir) { - node.requestCount = file.requestCount; - } - } - - if (isDir) { - if (!node.childMap) { - node.childMap = new Map(); - } - if (!node.children) { - node.children = []; - } - cursorMap = node.childMap; - } - } - } - - return toExplorerNodes(rootMap); -} - -export function flattenExplorerTree( - nodes: ExplorerNode[], - expandedDirs: ExplorerExpandedState -): ExplorerFlatNode[] { - const flattened: ExplorerFlatNode[] = []; - - const visit = (node: ExplorerNode) => { - const isExpanded = Boolean(expandedDirs[node.path]); - flattened.push({ - node, - isExpanded - }); - - if (!node.isDir || !isExpanded || !node.children) { - return; - } - - for (const child of node.children) { - visit(child); - } - }; - - for (const node of nodes) { - visit(node); - } - - return flattened; -} - -export function createInitialExpandedDirs(nodes: ExplorerNode[]): ExplorerExpandedState { - const initial: ExplorerExpandedState = {}; - - for (const node of nodes) { - if (node.isDir) { - initial[node.path] = true; - } - } - - return initial; -} - -export function findExplorerNode(nodes: ExplorerNode[], path: string): ExplorerNode | undefined { - for (const node of nodes) { - if (node.path === path) { - return node; - } - - if (node.children) { - const child = findExplorerNode(node.children, path); - if (child) { - return child; - } - } - } - - return undefined; -} - -export function hasExplorerPath(nodes: ExplorerNode[], path: string): boolean { - return Boolean(findExplorerNode(nodes, path)); -} - -export function pruneExpandedDirs( - expandedDirs: ExplorerExpandedState, - nodes: ExplorerNode[] -): ExplorerExpandedState { - const pruned: ExplorerExpandedState = {}; - - for (const [path, isExpanded] of Object.entries(expandedDirs)) { - const node = findExplorerNode(nodes, path); - if (node?.isDir) { - pruned[path] = Boolean(isExpanded); - } - } - - return pruned; -} +export { + buildExplorerTree, + createInitialExpandedDirs, + findExplorerNode, + flattenExplorerTree, + hasExplorerPath, + pruneExpandedDirs +} from '@t-req/ui/explorer'; diff --git a/packages/desktop/src/features/explorer/types.ts b/packages/desktop/src/features/explorer/types.ts index b5f04bb..eb5bff2 100644 --- a/packages/desktop/src/features/explorer/types.ts +++ b/packages/desktop/src/features/explorer/types.ts @@ -1,23 +1,9 @@ -export type ExplorerExpandedState = Record; - -export interface ExplorerFileEntry { - path: string; - requestCount?: number; -} - -export interface ExplorerNode { - name: string; - path: string; - isDir: boolean; - depth: number; - children?: ExplorerNode[]; - requestCount?: number; -} - -export interface ExplorerFlatNode { - node: ExplorerNode; - isExpanded: boolean; -} +export type { + ExplorerExpandedState, + ExplorerFileEntry, + ExplorerFlatNode, + ExplorerNode +} from '@t-req/ui/explorer'; export interface ExplorerFileDocument { path: string; diff --git a/packages/desktop/src/features/explorer/utils/request-workspace.ts b/packages/desktop/src/features/explorer/utils/request-workspace.ts index daaef5f..38325fb 100644 --- a/packages/desktop/src/features/explorer/utils/request-workspace.ts +++ b/packages/desktop/src/features/explorer/utils/request-workspace.ts @@ -1,41 +1,7 @@ -type RequestSummary = { - index: number; - name?: string; - method: string; - url: string; - protocol?: 'http' | 'sse' | 'ws'; -}; - -export type RequestOption = { - index: number; - label: string; - protocol?: 'http' | 'sse' | 'ws'; -}; - -export function isHttpProtocol(protocol: string | undefined): boolean { - return protocol === undefined || protocol === 'http'; -} - -export function toRequestOptionLabel(request: RequestSummary): string { - const prefix = `${request.index + 1}.`; - if (request.name) { - return `${prefix} ${request.name}`; - } - return `${prefix} ${request.method.toUpperCase()} ${request.url}`; -} - -export function toRequestOption(request: RequestSummary): RequestOption { - return { - index: request.index, - label: toRequestOptionLabel(request), - protocol: request.protocol - }; -} - -export function toRequestIndex(value: string): number | undefined { - const parsed = Number.parseInt(value, 10); - if (Number.isNaN(parsed)) { - return undefined; - } - return parsed; -} +export type { RequestOption } from '@t-req/ui/explorer'; +export { + isHttpProtocol, + toRequestIndex, + toRequestOption, + toRequestOptionLabel +} from '@t-req/ui/explorer'; diff --git a/packages/desktop/src/features/explorer/workspace-files.ts b/packages/desktop/src/features/explorer/workspace-files.ts index 0cfa90f..90e8cdd 100644 --- a/packages/desktop/src/features/explorer/workspace-files.ts +++ b/packages/desktop/src/features/explorer/workspace-files.ts @@ -1,19 +1 @@ -import type { ExplorerFileEntry } from './types'; - -const HTTP_FILE_EXTENSION = '.http'; - -type WorkspaceFileLike = { - path: string; - requestCount?: number; -}; - -function isHttpWorkspaceFile(file: WorkspaceFileLike): boolean { - return file.path.toLowerCase().endsWith(HTTP_FILE_EXTENSION); -} - -export function toExplorerFiles(files: WorkspaceFileLike[]): ExplorerFileEntry[] { - return files.filter(isHttpWorkspaceFile).map((file) => ({ - path: file.path, - requestCount: file.requestCount - })); -} +export { toExplorerFiles } from '@t-req/ui/explorer'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 75b23ba..58ce34b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,10 @@ "types": "./src/index.ts", "import": "./src/index.ts" }, + "./explorer": { + "types": "./src/explorer/index.ts", + "import": "./src/explorer/index.ts" + }, "./styles": { "types": "./src/styles/styles.d.ts", "default": "./src/styles/base.css" diff --git a/packages/ui/src/explorer/index.ts b/packages/ui/src/explorer/index.ts new file mode 100644 index 0000000..3bf5644 --- /dev/null +++ b/packages/ui/src/explorer/index.ts @@ -0,0 +1,23 @@ +export type { RequestOption } from './request-workspace.js'; +export { + isHttpProtocol, + toRequestIndex, + toRequestOption, + toRequestOptionLabel +} from './request-workspace.js'; +export type { BuildExplorerTreeOptions, ExplorerFileNodeComparator } from './tree.js'; +export { + buildExplorerTree, + createInitialExpandedDirs, + findExplorerNode, + flattenExplorerTree, + hasExplorerPath, + pruneExpandedDirs +} from './tree.js'; +export type { + ExplorerExpandedState, + ExplorerFileEntry, + ExplorerFlatNode, + ExplorerNode +} from './types.js'; +export { toExplorerFiles } from './workspace-files.js'; diff --git a/packages/ui/src/explorer/request-workspace.ts b/packages/ui/src/explorer/request-workspace.ts new file mode 100644 index 0000000..daaef5f --- /dev/null +++ b/packages/ui/src/explorer/request-workspace.ts @@ -0,0 +1,41 @@ +type RequestSummary = { + index: number; + name?: string; + method: string; + url: string; + protocol?: 'http' | 'sse' | 'ws'; +}; + +export type RequestOption = { + index: number; + label: string; + protocol?: 'http' | 'sse' | 'ws'; +}; + +export function isHttpProtocol(protocol: string | undefined): boolean { + return protocol === undefined || protocol === 'http'; +} + +export function toRequestOptionLabel(request: RequestSummary): string { + const prefix = `${request.index + 1}.`; + if (request.name) { + return `${prefix} ${request.name}`; + } + return `${prefix} ${request.method.toUpperCase()} ${request.url}`; +} + +export function toRequestOption(request: RequestSummary): RequestOption { + return { + index: request.index, + label: toRequestOptionLabel(request), + protocol: request.protocol + }; +} + +export function toRequestIndex(value: string): number | undefined { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return undefined; + } + return parsed; +} diff --git a/packages/ui/src/explorer/tree.ts b/packages/ui/src/explorer/tree.ts new file mode 100644 index 0000000..1f41d7c --- /dev/null +++ b/packages/ui/src/explorer/tree.ts @@ -0,0 +1,198 @@ +import type { + ExplorerExpandedState, + ExplorerFileEntry, + ExplorerFlatNode, + ExplorerNode +} from './types.js'; + +type MutableNode = ExplorerNode & { + childMap?: Map; +}; + +export type ExplorerFileNodeComparator = (a: ExplorerNode, b: ExplorerNode) => number; + +export type BuildExplorerTreeOptions = { + compareFiles?: ExplorerFileNodeComparator; +}; + +function normalizeRelativePath(path: string): string { + return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/'); +} + +function compareNodes( + a: ExplorerNode, + b: ExplorerNode, + compareFiles?: ExplorerFileNodeComparator +): number { + if (a.isDir && !b.isDir) return -1; + if (!a.isDir && b.isDir) return 1; + if (!a.isDir && !b.isDir && compareFiles) { + const result = compareFiles(a, b); + if (result !== 0) { + return result; + } + } + return a.name.localeCompare(b.name, undefined, { + sensitivity: 'base', + numeric: true + }); +} + +function toExplorerNodes( + map: Map, + compareFiles?: ExplorerFileNodeComparator +): ExplorerNode[] { + return Array.from(map.values()) + .map((node): ExplorerNode => { + const children = node.childMap ? toExplorerNodes(node.childMap, compareFiles) : undefined; + return { + name: node.name, + path: node.path, + isDir: node.isDir, + depth: node.depth, + children, + requestCount: node.requestCount + }; + }) + .sort((a, b) => compareNodes(a, b, compareFiles)); +} + +export function buildExplorerTree( + files: ExplorerFileEntry[], + options?: BuildExplorerTreeOptions +): ExplorerNode[] { + const compareFiles = options?.compareFiles; + const rootMap = new Map(); + + for (const file of files) { + const normalizedPath = normalizeRelativePath(file.path); + if (!normalizedPath) continue; + + const parts = normalizedPath.split('/'); + let currentPath = ''; + let cursorMap = rootMap; + + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part) continue; + + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isDir = index < parts.length - 1; + let node = cursorMap.get(part); + + if (!node) { + node = { + name: part, + path: currentPath, + isDir, + depth: index, + children: isDir ? [] : undefined, + requestCount: isDir ? undefined : file.requestCount, + childMap: isDir ? new Map() : undefined + }; + cursorMap.set(part, node); + } else { + if (isDir && !node.isDir) { + node.isDir = true; + node.requestCount = undefined; + node.children = []; + node.childMap = new Map(); + } + + if (!isDir) { + node.requestCount = file.requestCount; + } + } + + if (isDir) { + if (!node.childMap) { + node.childMap = new Map(); + } + if (!node.children) { + node.children = []; + } + cursorMap = node.childMap; + } + } + } + + return toExplorerNodes(rootMap, compareFiles); +} + +export function flattenExplorerTree( + nodes: ExplorerNode[], + expandedDirs: ExplorerExpandedState +): ExplorerFlatNode[] { + const flattened: ExplorerFlatNode[] = []; + + const visit = (node: ExplorerNode) => { + const isExpanded = Boolean(expandedDirs[node.path]); + flattened.push({ + node, + isExpanded + }); + + if (!node.isDir || !isExpanded || !node.children) { + return; + } + + for (const child of node.children) { + visit(child); + } + }; + + for (const node of nodes) { + visit(node); + } + + return flattened; +} + +export function createInitialExpandedDirs(nodes: ExplorerNode[]): ExplorerExpandedState { + const initial: ExplorerExpandedState = {}; + + for (const node of nodes) { + if (node.isDir) { + initial[node.path] = true; + } + } + + return initial; +} + +export function findExplorerNode(nodes: ExplorerNode[], path: string): ExplorerNode | undefined { + for (const node of nodes) { + if (node.path === path) { + return node; + } + + if (node.children) { + const child = findExplorerNode(node.children, path); + if (child) { + return child; + } + } + } + + return undefined; +} + +export function hasExplorerPath(nodes: ExplorerNode[], path: string): boolean { + return Boolean(findExplorerNode(nodes, path)); +} + +export function pruneExpandedDirs( + expandedDirs: ExplorerExpandedState, + nodes: ExplorerNode[] +): ExplorerExpandedState { + const pruned: ExplorerExpandedState = {}; + + for (const [path, isExpanded] of Object.entries(expandedDirs)) { + const node = findExplorerNode(nodes, path); + if (node?.isDir) { + pruned[path] = Boolean(isExpanded); + } + } + + return pruned; +} diff --git a/packages/ui/src/explorer/types.ts b/packages/ui/src/explorer/types.ts new file mode 100644 index 0000000..b3410e5 --- /dev/null +++ b/packages/ui/src/explorer/types.ts @@ -0,0 +1,20 @@ +export type ExplorerExpandedState = Record; + +export interface ExplorerFileEntry { + path: string; + requestCount?: number; +} + +export interface ExplorerNode { + name: string; + path: string; + isDir: boolean; + depth: number; + children?: ExplorerNode[]; + requestCount?: number; +} + +export interface ExplorerFlatNode { + node: ExplorerNode; + isExpanded: boolean; +} diff --git a/packages/ui/src/explorer/workspace-files.ts b/packages/ui/src/explorer/workspace-files.ts new file mode 100644 index 0000000..7d47dfe --- /dev/null +++ b/packages/ui/src/explorer/workspace-files.ts @@ -0,0 +1,19 @@ +import type { ExplorerFileEntry } from './types.js'; + +const HTTP_FILE_EXTENSION = '.http'; + +type WorkspaceFileLike = { + path: string; + requestCount?: number; +}; + +function isHttpWorkspaceFile(file: WorkspaceFileLike): boolean { + return file.path.toLowerCase().endsWith(HTTP_FILE_EXTENSION); +} + +export function toExplorerFiles(files: WorkspaceFileLike[]): ExplorerFileEntry[] { + return files.filter(isHttpWorkspaceFile).map((file) => ({ + path: file.path, + requestCount: file.requestCount + })); +} diff --git a/packages/web/src/stores/workspace.ts b/packages/web/src/stores/workspace.ts index a9f9a2c..bf47699 100644 --- a/packages/web/src/stores/workspace.ts +++ b/packages/web/src/stores/workspace.ts @@ -1,4 +1,13 @@ import { unwrap } from '@t-req/sdk/client'; +import { + buildExplorerTree, + createInitialExpandedDirs, + type ExplorerExpandedState, + type ExplorerFileNodeComparator, + type ExplorerFlatNode, + type ExplorerNode, + flattenExplorerTree +} from '@t-req/ui/explorer'; import { createMemo, createSignal } from 'solid-js'; import { createStore, produce } from 'solid-js/store'; import type { ConnectionState } from '../context/sdk'; @@ -6,19 +15,8 @@ import { createTreqWebClient, type WorkspaceFile, type WorkspaceRequest } from ' export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; -export interface TreeNode { - name: string; - path: string; - isDir: boolean; - children?: TreeNode[]; - depth: number; - requestCount?: number; -} - -export interface FlatNode { - node: TreeNode; - isExpanded: boolean; -} +export type TreeNode = ExplorerNode; +export type FlatNode = ExplorerFlatNode; export interface FileContent { content: string; @@ -29,12 +27,29 @@ export interface FileContent { } interface DeepState { - expandedDirs: Record; + expandedDirs: ExplorerExpandedState; requestsByPath: Record; fileContents: Record; unsavedChanges: Record; } +function isRequestFileName(name: string): boolean { + const lowerName = name.toLowerCase(); + return lowerName.endsWith('.http') || lowerName.endsWith('.rest'); +} + +const compareRequestFilesFirst: ExplorerFileNodeComparator = (a, b) => { + const aIsRequestFile = isRequestFileName(a.name); + const bIsRequestFile = isRequestFileName(b.name); + if (aIsRequestFile && !bIsRequestFile) { + return -1; + } + if (!aIsRequestFile && bIsRequestFile) { + return 1; + } + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); +}; + export interface WorkspaceStoreDeps { connection: () => ConnectionState; setConnection: (connection: ConnectionState) => void; @@ -56,7 +71,7 @@ export interface WorkspaceStore { // Tree state tree: () => TreeNode[]; flattenedVisible: () => FlatNode[]; - expandedDirs: () => Record; + expandedDirs: () => ExplorerExpandedState; toggleDir: (path: string) => void; // Selection state @@ -100,115 +115,6 @@ export interface WorkspaceStore { hasUnsavedChanges: (path: string) => boolean; } -function buildTree(files: WorkspaceFile[]): TreeNode[] { - const dirMaps = new Map>(); - const root = new Map(); - dirMaps.set('', root); - - for (const file of files) { - const parts = file.path.split('/').filter(Boolean); - let currentPath = ''; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - - const parentPath = currentPath; - currentPath = currentPath ? `${currentPath}/${part}` : part; - const isLast = i === parts.length - 1; - - let parentMap = dirMaps.get(parentPath); - if (!parentMap) { - parentMap = new Map(); - dirMaps.set(parentPath, parentMap); - } - - let node = parentMap.get(part); - - if (!node) { - node = { - name: part, - path: currentPath, - isDir: !isLast, - depth: i, - children: isLast ? undefined : [], - requestCount: isLast ? file.requestCount : undefined - }; - parentMap.set(part, node); - - if (!isLast) { - dirMaps.set(currentPath, new Map()); - } - } - - if (!isLast && node.children) { - const childMap = dirMaps.get(currentPath); - if (childMap) { - node.children = Array.from(childMap.values()); - } - } - } - } - - // Final pass: ensure all directory children arrays are populated - for (const [path, map] of dirMaps) { - if (path === '') continue; - const parentPath = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''; - const name = path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path; - const parentMap = dirMaps.get(parentPath); - if (parentMap) { - const node = parentMap.get(name); - if (node?.isDir) { - node.children = Array.from(map.values()); - } - } - } - - return sortNodes(Array.from(root.values())); -} - -function isHttpFile(name: string): boolean { - return name.endsWith('.http') || name.endsWith('.rest'); -} - -function sortNodes(nodes: TreeNode[]): TreeNode[] { - return nodes - .map((node) => ({ - ...node, - children: node.children ? sortNodes(node.children) : undefined - })) - .sort((a, b) => { - if (a.isDir && !b.isDir) return -1; - if (!a.isDir && b.isDir) return 1; - const aIsHttp = isHttpFile(a.name); - const bIsHttp = isHttpFile(b.name); - if (aIsHttp && !bIsHttp) return -1; - if (!aIsHttp && bIsHttp) return 1; - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }); -} - -function flattenTree(nodes: TreeNode[], expandedDirs: Record): FlatNode[] { - const result: FlatNode[] = []; - - function traverse(node: TreeNode) { - const isExpanded = !!expandedDirs[node.path]; - result.push({ node, isExpanded }); - - if (node.isDir && isExpanded && node.children) { - for (const child of node.children) { - traverse(child); - } - } - } - - for (const node of nodes) { - traverse(node); - } - - return result; -} - // ============================================================================ // Store Factory // ============================================================================ @@ -237,8 +143,12 @@ export function createWorkspaceStore(deps: WorkspaceStoreDeps): WorkspaceStore { }); // ── Derived ───────────────────────────────────────────────────────────── - const tree = createMemo(() => buildTree(files())); - const flattenedVisible = createMemo(() => flattenTree(tree(), deep.expandedDirs)); + const tree = createMemo(() => + buildExplorerTree(files(), { + compareFiles: compareRequestFilesFirst + }) + ); + const flattenedVisible = createMemo(() => flattenExplorerTree(tree(), deep.expandedDirs)); const selectedNode = createMemo(() => { const path = selectedPath(); @@ -292,14 +202,14 @@ export function createWorkspaceStore(deps: WorkspaceStoreDeps): WorkspaceStore { } // Auto-expand first level directories - const firstLevelDirs = response.files - .map((f) => f.path.split('/')[0]) - .filter((v, i, a) => a.indexOf(v) === i); - const expanded: Record = {}; - for (const dir of firstLevelDirs) { - expanded[dir] = true; - } - setDeep('expandedDirs', expanded); + setDeep( + 'expandedDirs', + createInitialExpandedDirs( + buildExplorerTree(response.files, { + compareFiles: compareRequestFilesFirst + }) + ) + ); } catch (err) { setConnectionStatus('error'); setError(err instanceof Error ? err.message : String(err));