Skip to content

Commit b233a29

Browse files
refactor(ui,desktop,web): extract shared explorer tree utilities to @… (#100)
* refactor(ui,desktop,web): extract shared explorer tree utilities to @t-req/ui Consolidate duplicate explorer tree logic from desktop and web packages into a new shared @t-req/ui/explorer submodule. This eliminates code duplication and centralizes tree building, flattening, and file utilities. - Create packages/ui/src/explorer/ with shared types and utilities - Refactor desktop explorer to re-export from shared module - Refactor web workspace store to use shared explorer utilities - Add ./explorer export to packages/ui/package.json
1 parent 1595c48 commit b233a29

File tree

12 files changed

+403
-386
lines changed

12 files changed

+403
-386
lines changed

packages/desktop/src/features/explorer/tree.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,38 @@ describe('buildExplorerTree', () => {
2525
'src/Beta.http'
2626
]);
2727
});
28+
29+
it('supports custom file ordering for non-directory nodes', () => {
30+
const tree = buildExplorerTree(
31+
[
32+
{ path: 'src/request.ts' },
33+
{ path: 'src/api.http' },
34+
{ path: 'src/events.rest' },
35+
{ path: 'src/worker.ts' }
36+
],
37+
{
38+
compareFiles: (a, b) => {
39+
const aIsRequestFile = a.name.endsWith('.http') || a.name.endsWith('.rest');
40+
const bIsRequestFile = b.name.endsWith('.http') || b.name.endsWith('.rest');
41+
if (aIsRequestFile && !bIsRequestFile) {
42+
return -1;
43+
}
44+
if (!aIsRequestFile && bIsRequestFile) {
45+
return 1;
46+
}
47+
return 0;
48+
}
49+
}
50+
);
51+
52+
const srcNode = tree.find((node) => node.path === 'src');
53+
expect(srcNode?.children?.map((node) => node.path)).toEqual([
54+
'src/api.http',
55+
'src/events.rest',
56+
'src/request.ts',
57+
'src/worker.ts'
58+
]);
59+
});
2860
});
2961

3062
describe('flattenExplorerTree', () => {
Lines changed: 8 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,172 +1,8 @@
1-
import type {
2-
ExplorerExpandedState,
3-
ExplorerFileEntry,
4-
ExplorerFlatNode,
5-
ExplorerNode
6-
} from './types';
7-
import { normalizeRelativePath } from './utils/path';
8-
9-
type MutableNode = ExplorerNode & {
10-
childMap?: Map<string, MutableNode>;
11-
};
12-
13-
function compareNodes(a: ExplorerNode, b: ExplorerNode): number {
14-
if (a.isDir && !b.isDir) return -1;
15-
if (!a.isDir && b.isDir) return 1;
16-
return a.name.localeCompare(b.name, undefined, {
17-
sensitivity: 'base',
18-
numeric: true
19-
});
20-
}
21-
22-
function toExplorerNodes(map: Map<string, MutableNode>): ExplorerNode[] {
23-
return Array.from(map.values())
24-
.map((node): ExplorerNode => {
25-
const children = node.childMap ? toExplorerNodes(node.childMap) : undefined;
26-
return {
27-
name: node.name,
28-
path: node.path,
29-
isDir: node.isDir,
30-
depth: node.depth,
31-
children,
32-
requestCount: node.requestCount
33-
};
34-
})
35-
.sort(compareNodes);
36-
}
37-
38-
export function buildExplorerTree(files: ExplorerFileEntry[]): ExplorerNode[] {
39-
const rootMap = new Map<string, MutableNode>();
40-
41-
for (const file of files) {
42-
const normalizedPath = normalizeRelativePath(file.path);
43-
if (!normalizedPath) continue;
44-
45-
const parts = normalizedPath.split('/');
46-
let currentPath = '';
47-
let cursorMap = rootMap;
48-
49-
for (let index = 0; index < parts.length; index += 1) {
50-
const part = parts[index];
51-
if (!part) continue;
52-
53-
currentPath = currentPath ? `${currentPath}/${part}` : part;
54-
const isDir = index < parts.length - 1;
55-
let node = cursorMap.get(part);
56-
57-
if (!node) {
58-
node = {
59-
name: part,
60-
path: currentPath,
61-
isDir,
62-
depth: index,
63-
children: isDir ? [] : undefined,
64-
requestCount: isDir ? undefined : file.requestCount,
65-
childMap: isDir ? new Map<string, MutableNode>() : undefined
66-
};
67-
cursorMap.set(part, node);
68-
} else {
69-
if (isDir && !node.isDir) {
70-
node.isDir = true;
71-
node.requestCount = undefined;
72-
node.children = [];
73-
node.childMap = new Map<string, MutableNode>();
74-
}
75-
76-
if (!isDir) {
77-
node.requestCount = file.requestCount;
78-
}
79-
}
80-
81-
if (isDir) {
82-
if (!node.childMap) {
83-
node.childMap = new Map<string, MutableNode>();
84-
}
85-
if (!node.children) {
86-
node.children = [];
87-
}
88-
cursorMap = node.childMap;
89-
}
90-
}
91-
}
92-
93-
return toExplorerNodes(rootMap);
94-
}
95-
96-
export function flattenExplorerTree(
97-
nodes: ExplorerNode[],
98-
expandedDirs: ExplorerExpandedState
99-
): ExplorerFlatNode[] {
100-
const flattened: ExplorerFlatNode[] = [];
101-
102-
const visit = (node: ExplorerNode) => {
103-
const isExpanded = Boolean(expandedDirs[node.path]);
104-
flattened.push({
105-
node,
106-
isExpanded
107-
});
108-
109-
if (!node.isDir || !isExpanded || !node.children) {
110-
return;
111-
}
112-
113-
for (const child of node.children) {
114-
visit(child);
115-
}
116-
};
117-
118-
for (const node of nodes) {
119-
visit(node);
120-
}
121-
122-
return flattened;
123-
}
124-
125-
export function createInitialExpandedDirs(nodes: ExplorerNode[]): ExplorerExpandedState {
126-
const initial: ExplorerExpandedState = {};
127-
128-
for (const node of nodes) {
129-
if (node.isDir) {
130-
initial[node.path] = true;
131-
}
132-
}
133-
134-
return initial;
135-
}
136-
137-
export function findExplorerNode(nodes: ExplorerNode[], path: string): ExplorerNode | undefined {
138-
for (const node of nodes) {
139-
if (node.path === path) {
140-
return node;
141-
}
142-
143-
if (node.children) {
144-
const child = findExplorerNode(node.children, path);
145-
if (child) {
146-
return child;
147-
}
148-
}
149-
}
150-
151-
return undefined;
152-
}
153-
154-
export function hasExplorerPath(nodes: ExplorerNode[], path: string): boolean {
155-
return Boolean(findExplorerNode(nodes, path));
156-
}
157-
158-
export function pruneExpandedDirs(
159-
expandedDirs: ExplorerExpandedState,
160-
nodes: ExplorerNode[]
161-
): ExplorerExpandedState {
162-
const pruned: ExplorerExpandedState = {};
163-
164-
for (const [path, isExpanded] of Object.entries(expandedDirs)) {
165-
const node = findExplorerNode(nodes, path);
166-
if (node?.isDir) {
167-
pruned[path] = Boolean(isExpanded);
168-
}
169-
}
170-
171-
return pruned;
172-
}
1+
export {
2+
buildExplorerTree,
3+
createInitialExpandedDirs,
4+
findExplorerNode,
5+
flattenExplorerTree,
6+
hasExplorerPath,
7+
pruneExpandedDirs
8+
} from '@t-req/ui/explorer';

packages/desktop/src/features/explorer/types.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
1-
export type ExplorerExpandedState = Record<string, boolean>;
2-
3-
export interface ExplorerFileEntry {
4-
path: string;
5-
requestCount?: number;
6-
}
7-
8-
export interface ExplorerNode {
9-
name: string;
10-
path: string;
11-
isDir: boolean;
12-
depth: number;
13-
children?: ExplorerNode[];
14-
requestCount?: number;
15-
}
16-
17-
export interface ExplorerFlatNode {
18-
node: ExplorerNode;
19-
isExpanded: boolean;
20-
}
1+
export type {
2+
ExplorerExpandedState,
3+
ExplorerFileEntry,
4+
ExplorerFlatNode,
5+
ExplorerNode
6+
} from '@t-req/ui/explorer';
217

228
export interface ExplorerFileDocument {
239
path: string;
Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,7 @@
1-
type RequestSummary = {
2-
index: number;
3-
name?: string;
4-
method: string;
5-
url: string;
6-
protocol?: 'http' | 'sse' | 'ws';
7-
};
8-
9-
export type RequestOption = {
10-
index: number;
11-
label: string;
12-
protocol?: 'http' | 'sse' | 'ws';
13-
};
14-
15-
export function isHttpProtocol(protocol: string | undefined): boolean {
16-
return protocol === undefined || protocol === 'http';
17-
}
18-
19-
export function toRequestOptionLabel(request: RequestSummary): string {
20-
const prefix = `${request.index + 1}.`;
21-
if (request.name) {
22-
return `${prefix} ${request.name}`;
23-
}
24-
return `${prefix} ${request.method.toUpperCase()} ${request.url}`;
25-
}
26-
27-
export function toRequestOption(request: RequestSummary): RequestOption {
28-
return {
29-
index: request.index,
30-
label: toRequestOptionLabel(request),
31-
protocol: request.protocol
32-
};
33-
}
34-
35-
export function toRequestIndex(value: string): number | undefined {
36-
const parsed = Number.parseInt(value, 10);
37-
if (Number.isNaN(parsed)) {
38-
return undefined;
39-
}
40-
return parsed;
41-
}
1+
export type { RequestOption } from '@t-req/ui/explorer';
2+
export {
3+
isHttpProtocol,
4+
toRequestIndex,
5+
toRequestOption,
6+
toRequestOptionLabel
7+
} from '@t-req/ui/explorer';
Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1 @@
1-
import type { ExplorerFileEntry } from './types';
2-
3-
const HTTP_FILE_EXTENSION = '.http';
4-
5-
type WorkspaceFileLike = {
6-
path: string;
7-
requestCount?: number;
8-
};
9-
10-
function isHttpWorkspaceFile(file: WorkspaceFileLike): boolean {
11-
return file.path.toLowerCase().endsWith(HTTP_FILE_EXTENSION);
12-
}
13-
14-
export function toExplorerFiles(files: WorkspaceFileLike[]): ExplorerFileEntry[] {
15-
return files.filter(isHttpWorkspaceFile).map((file) => ({
16-
path: file.path,
17-
requestCount: file.requestCount
18-
}));
19-
}
1+
export { toExplorerFiles } from '@t-req/ui/explorer';

packages/ui/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"types": "./src/index.ts",
3131
"import": "./src/index.ts"
3232
},
33+
"./explorer": {
34+
"types": "./src/explorer/index.ts",
35+
"import": "./src/explorer/index.ts"
36+
},
3337
"./styles": {
3438
"types": "./src/styles/styles.d.ts",
3539
"default": "./src/styles/base.css"

packages/ui/src/explorer/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type { RequestOption } from './request-workspace.js';
2+
export {
3+
isHttpProtocol,
4+
toRequestIndex,
5+
toRequestOption,
6+
toRequestOptionLabel
7+
} from './request-workspace.js';
8+
export type { BuildExplorerTreeOptions, ExplorerFileNodeComparator } from './tree.js';
9+
export {
10+
buildExplorerTree,
11+
createInitialExpandedDirs,
12+
findExplorerNode,
13+
flattenExplorerTree,
14+
hasExplorerPath,
15+
pruneExpandedDirs
16+
} from './tree.js';
17+
export type {
18+
ExplorerExpandedState,
19+
ExplorerFileEntry,
20+
ExplorerFlatNode,
21+
ExplorerNode
22+
} from './types.js';
23+
export { toExplorerFiles } from './workspace-files.js';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
type RequestSummary = {
2+
index: number;
3+
name?: string;
4+
method: string;
5+
url: string;
6+
protocol?: 'http' | 'sse' | 'ws';
7+
};
8+
9+
export type RequestOption = {
10+
index: number;
11+
label: string;
12+
protocol?: 'http' | 'sse' | 'ws';
13+
};
14+
15+
export function isHttpProtocol(protocol: string | undefined): boolean {
16+
return protocol === undefined || protocol === 'http';
17+
}
18+
19+
export function toRequestOptionLabel(request: RequestSummary): string {
20+
const prefix = `${request.index + 1}.`;
21+
if (request.name) {
22+
return `${prefix} ${request.name}`;
23+
}
24+
return `${prefix} ${request.method.toUpperCase()} ${request.url}`;
25+
}
26+
27+
export function toRequestOption(request: RequestSummary): RequestOption {
28+
return {
29+
index: request.index,
30+
label: toRequestOptionLabel(request),
31+
protocol: request.protocol
32+
};
33+
}
34+
35+
export function toRequestIndex(value: string): number | undefined {
36+
const parsed = Number.parseInt(value, 10);
37+
if (Number.isNaN(parsed)) {
38+
return undefined;
39+
}
40+
return parsed;
41+
}

0 commit comments

Comments
 (0)