mirror of
https://github.com/multipleof4/dsh-context-plugin.git
synced 2026-09-18 11:35:44 +00:00
feat: initial dsh-context-plugin implementation
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# DeepSeek Harness Context Plugin (`dsh-context-plugin`)
|
||||||
|
|
||||||
|
A dynamic Cordis plugin for DeepSeek Harness (DSH) that adds an interactive UI window to scan workspace files and ingest them directly into the active chat session as individual prompt turns.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Interactive UI Window**:
|
||||||
|
- Rendered in `shell.overlay` with support for minimize and close.
|
||||||
|
- Quick launcher button docked in `conversation.session.header.actions`.
|
||||||
|
- Button dynamically displays the active workspace root directory.
|
||||||
|
- Editable input override to type or change the workspace directory path.
|
||||||
|
- **Header Link Formatting**:
|
||||||
|
- Configurable switch between standard Linux `file:///` URIs and IDE `vscode://file/` URIs.
|
||||||
|
- Link format: `[`<relative-path>`](file://<abs-path>)`.
|
||||||
|
- **Dynamic Backtick Escaping**:
|
||||||
|
- Dynamically calculates enclosing code fence backtick length ($\ge 3$ backticks) so files containing markdown or nested code fences never break the outer block.
|
||||||
|
- **Binary / Non-Text File Detection**:
|
||||||
|
- Inspects file extensions and scans the first 8 KB for null bytes (`\0`).
|
||||||
|
- Replaces binary contents with `*Binary file - content not displayed.*`.
|
||||||
|
- **Git & Ignore Rules**:
|
||||||
|
- Always ignores `.git/` across all directories.
|
||||||
|
- Respects `.gitignore` and `.dshignore` rules from the workspace root (including negation `!` patterns).
|
||||||
|
- Skips walking ignored directories entirely for performance.
|
||||||
|
- **Real-Time Progress Feedback**:
|
||||||
|
- Live animated spinner and counter (`Reading 5/42...`, `Injecting 5/42...`).
|
||||||
|
- Visual progress bar.
|
||||||
|
- Interactive file selection checklist before ingestion.
|
||||||
|
- **Individual User Prompt Turns**:
|
||||||
|
- Dispatches each file as an individual prompt turn into the chat session queue.
|
||||||
|
|
||||||
|
## Prompt Turn Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[`relative/path/to/file`](file:///absolute/path/to/workspace/relative/path/to/file)
|
||||||
|
|
||||||
|
```{extension}
|
||||||
|
{file_contents}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `src/host.js`: Node.js host-side plugin handling filesystem queries, ignore rule resolution, and binary checks.
|
||||||
|
- `src/client.js`: Browser client-side plugin rendering the floating overlay window and header action button in Cordis slots.
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "dsh-context-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "DeepSeek Harness plugin for scanning and ingesting workspace files into chat context turns",
|
||||||
|
"main": "src/host.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"deepseek-harness",
|
||||||
|
"cordis",
|
||||||
|
"plugin",
|
||||||
|
"context",
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
745
src/client.js
Normal file
745
src/client.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek Harness Context Plugin - Client Implementation
|
||||||
|
*
|
||||||
|
* Registers:
|
||||||
|
* 1. An interactive floating UI window in `shell.overlay`
|
||||||
|
* 2. An action button in `conversation.session.header.actions`
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays active workspace path with editable input override
|
||||||
|
* - Configurable link switch between `file:///` and `vscode://file/`
|
||||||
|
* - Scans workspace files respecting .gitignore, .dshignore, and .git
|
||||||
|
* - Detects binary files and replaces content with notice
|
||||||
|
* - Dynamically calculates code fence backticks (>= 3 backticks)
|
||||||
|
* - Shows loading spinner and progress counter (Reading X/N, Injecting X/N)
|
||||||
|
* - Delivers each file as an individual prompt turn into the chat session
|
||||||
|
*/
|
||||||
|
|
||||||
|
function clientPlugin() {
|
||||||
|
return {
|
||||||
|
inject: ['timer'],
|
||||||
|
apply(ctx) {
|
||||||
|
const h = React.createElement;
|
||||||
|
const slots = ctx.get('slots');
|
||||||
|
if (!slots) return;
|
||||||
|
|
||||||
|
// Global window state emitter so Header Action Button and Overlay Window stay in sync
|
||||||
|
const listeners = new Set();
|
||||||
|
const windowState = {
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateWindowState(patch) {
|
||||||
|
Object.assign(windowState, patch);
|
||||||
|
for (const cb of listeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSharedWindowState() {
|
||||||
|
const [state, setState] = React.useState({ ...windowState });
|
||||||
|
React.useEffect(() => {
|
||||||
|
const cb = () => setState({ ...windowState });
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
}, []);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject custom CSS styling
|
||||||
|
if (typeof styles !== 'undefined' && styles.insert) {
|
||||||
|
styles.insert(`
|
||||||
|
.dsh-ctx-overlay-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 60px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: auto;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dsh-ctx-window {
|
||||||
|
width: 470px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
background: var(--dsw-alias-bg-overlay, #1c1d22);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.18));
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);
|
||||||
|
color: var(--dsw-alias-label-primary, #f0f0f0);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: dshCtxFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes dshCtxFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.dsh-ctx-header {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--dsw-alias-bg-layer-1, #23242a);
|
||||||
|
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.1));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dsh-ctx-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-header-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--dsw-alias-label-secondary, #999);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.dsh-ctx-header-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--dsw-alias-label-primary, #fff);
|
||||||
|
}
|
||||||
|
.dsh-ctx-body {
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--dsw-alias-label-secondary, #aaa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--dsw-alias-bg-layer-2, #141417);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--dsw-alias-label-primary, #f0f0f0);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dsh-ctx-input:focus {
|
||||||
|
border-color: var(--dsw-alias-brand-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
.dsh-ctx-btn {
|
||||||
|
background: var(--dsw-alias-bg-layer-2, #2a2b33);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.15));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--dsw-alias-label-primary, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-btn:hover:not(:disabled) {
|
||||||
|
background: var(--dsw-alias-bg-layer-1, #363842);
|
||||||
|
border-color: var(--dsw-alias-border-l1, rgba(255,255,255,0.3));
|
||||||
|
}
|
||||||
|
.dsh-ctx-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.dsh-ctx-btn-primary {
|
||||||
|
background: var(--dsw-alias-brand-primary, #2563eb);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-btn-primary:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.dsh-ctx-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--dsw-alias-bg-layer-2, #141417);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-switch-pills {
|
||||||
|
display: flex;
|
||||||
|
background: var(--dsw-alias-bg-layer-1, #23242a);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-pill {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dsw-alias-label-secondary, #999);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.dsh-ctx-pill.active {
|
||||||
|
background: var(--dsw-alias-brand-primary, #2563eb);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.dsh-ctx-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
color: var(--dsw-alias-label-secondary, #aaa);
|
||||||
|
}
|
||||||
|
.dsh-ctx-badge.found {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-list {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--dsw-alias-bg-layer-2, #141417);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-item:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.dsh-ctx-file-tag.bin {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.dsh-ctx-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--dsw-alias-bg-layer-2, #141417);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-progress-bar-bg {
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dsh-ctx-progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--dsw-alias-brand-primary, #3b82f6);
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
.dsh-ctx-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: dshCtxSpin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes dshCtxSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.dsh-ctx-min-badge {
|
||||||
|
background: var(--dsw-alias-bg-overlay, #1c1d22);
|
||||||
|
border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.25));
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--dsw-alias-label-primary, #fff);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.dsh-ctx-min-badge:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
.dsh-ctx-notice-success {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dsh-ctx-notice-error {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Window Component mounted in shell.overlay
|
||||||
|
function ContextOverlayWindow(props) {
|
||||||
|
const win = useSharedWindowState();
|
||||||
|
const sessionsState = props.useSessions ? props.useSessions(s => s) : null;
|
||||||
|
const workspacesState = props.useWorkspaces ? props.useWorkspaces(s => s) : null;
|
||||||
|
|
||||||
|
const [workspacePath, setWorkspacePath] = React.useState('');
|
||||||
|
const [scheme, setScheme] = React.useState('file'); // 'file' | 'vscode'
|
||||||
|
const [scanResult, setScanResult] = React.useState(null);
|
||||||
|
const [selectedFiles, setSelectedFiles] = React.useState(new Set());
|
||||||
|
const [phase, setPhase] = React.useState('idle'); // 'idle' | 'scanning' | 'reading' | 'injecting' | 'completed' | 'error'
|
||||||
|
const [progress, setProgress] = React.useState({ current: 0, total: 0, file: '', phaseText: '' });
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
|
const [successMessage, setSuccessMessage] = React.useState('');
|
||||||
|
|
||||||
|
// Attempt automatic workspace path detection from Host and Client
|
||||||
|
React.useEffect(() => {
|
||||||
|
host.call('get-workspace-root', {}).then(res => {
|
||||||
|
if (res && res.workspaceRoot) {
|
||||||
|
setWorkspacePath(prev => prev || res.workspaceRoot);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!workspacePath && workspacesState?.items?.length) {
|
||||||
|
const currentSessionId = sessionsState?.current;
|
||||||
|
let found = null;
|
||||||
|
if (currentSessionId) {
|
||||||
|
found = workspacesState.items.find(w => w.sessionIds && w.sessionIds.includes(currentSessionId));
|
||||||
|
}
|
||||||
|
if (!found) found = workspacesState.items[0];
|
||||||
|
if (found && found.path) {
|
||||||
|
setWorkspacePath(found.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [workspacesState, sessionsState, workspacePath]);
|
||||||
|
|
||||||
|
// Scan files in workspace
|
||||||
|
const handleScan = React.useCallback(async () => {
|
||||||
|
setPhase('scanning');
|
||||||
|
setErrorMessage('');
|
||||||
|
setSuccessMessage('');
|
||||||
|
try {
|
||||||
|
const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() });
|
||||||
|
if (res.rootPath) {
|
||||||
|
setWorkspacePath(res.rootPath);
|
||||||
|
}
|
||||||
|
setScanResult(res);
|
||||||
|
setSelectedFiles(new Set(res.files.map(f => f.relativePath)));
|
||||||
|
setPhase('idle');
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage('Scan failed: ' + (err.message || String(err)));
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
}, [workspacePath]);
|
||||||
|
|
||||||
|
// Toggle file selection
|
||||||
|
const toggleFile = (relPath) => {
|
||||||
|
setSelectedFiles(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(relPath)) next.delete(relPath);
|
||||||
|
else next.add(relPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (!scanResult) return;
|
||||||
|
if (selectedFiles.size === scanResult.files.length) {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedFiles(new Set(scanResult.files.map(f => f.relativePath)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ingest files into chat session
|
||||||
|
const handleIngest = async () => {
|
||||||
|
setErrorMessage('');
|
||||||
|
setSuccessMessage('');
|
||||||
|
|
||||||
|
// 1. Resolve active session
|
||||||
|
let session = null;
|
||||||
|
try {
|
||||||
|
const sessionsService = ctx.get('sessions');
|
||||||
|
const currentSessionId = sessionsState?.current || props.sessionId;
|
||||||
|
if (currentSessionId && sessionsService) {
|
||||||
|
const binding = sessionsService.binding(currentSessionId);
|
||||||
|
session = binding?.session;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Session resolution error', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
setErrorMessage('No active session found. Please open a chat session before ingesting files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan if not already scanned
|
||||||
|
let targetFiles = [];
|
||||||
|
if (!scanResult || scanResult.files.length === 0) {
|
||||||
|
setPhase('scanning');
|
||||||
|
setProgress({ current: 0, total: 0, file: '', phaseText: 'Scanning workspace files...' });
|
||||||
|
try {
|
||||||
|
const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() });
|
||||||
|
if (res.rootPath) setWorkspacePath(res.rootPath);
|
||||||
|
setScanResult(res);
|
||||||
|
targetFiles = res.files;
|
||||||
|
setSelectedFiles(new Set(res.files.map(f => f.relativePath)));
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage('Scan failed: ' + (err.message || String(err)));
|
||||||
|
setPhase('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
targetFiles = scanResult.files.filter(f => selectedFiles.has(f.relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFiles.length === 0) {
|
||||||
|
setErrorMessage('No files selected for ingestion.');
|
||||||
|
setPhase('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = targetFiles.length;
|
||||||
|
|
||||||
|
// 3. Process each file
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const file = targetFiles[i];
|
||||||
|
const idx = i + 1;
|
||||||
|
|
||||||
|
// Step 3a: Read file
|
||||||
|
setPhase('reading');
|
||||||
|
setProgress({
|
||||||
|
current: idx,
|
||||||
|
total,
|
||||||
|
file: file.relativePath,
|
||||||
|
phaseText: `Reading ${idx}/${total}: ${file.relativePath}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const readRes = await host.call('read-file', {
|
||||||
|
absolutePath: file.absolutePath,
|
||||||
|
relativePath: file.relativePath,
|
||||||
|
isBinaryByExt: file.isBinaryByExt,
|
||||||
|
extension: file.extension
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3b: Build header link
|
||||||
|
const abs = file.absolutePath.startsWith('/') ? file.absolutePath : ('/' + file.absolutePath);
|
||||||
|
const headerLink = scheme === 'vscode'
|
||||||
|
? `[\`${file.relativePath}\`](vscode://file${abs})`
|
||||||
|
: `[\`${file.relativePath}\`](file://${abs})`;
|
||||||
|
|
||||||
|
// Step 3c: Format the individual turn
|
||||||
|
const fence = readRes.fence || '```';
|
||||||
|
const ext = readRes.extension || '';
|
||||||
|
const content = readRes.content || '';
|
||||||
|
const normalizedContent = content.endsWith('\n') ? content : (content + '\n');
|
||||||
|
const turnText = `${headerLink}\n\n${fence}${ext}\n${normalizedContent}${fence}`;
|
||||||
|
|
||||||
|
// Step 3d: Inject prompt turn
|
||||||
|
setPhase('injecting');
|
||||||
|
setProgress({
|
||||||
|
current: idx,
|
||||||
|
total,
|
||||||
|
file: file.relativePath,
|
||||||
|
phaseText: `Injecting ${idx}/${total}: ${file.relativePath}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptOutcome = await session.prompt([{ type: 'text', text: turnText }], 'queue');
|
||||||
|
if (!promptOutcome || !promptOutcome.ok) {
|
||||||
|
console.error('Failed prompt turn for', file.relativePath, promptOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay to smoothly queue messages
|
||||||
|
await ctx.timeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('completed');
|
||||||
|
setSuccessMessage(`Successfully injected ${total} file${total > 1 ? 's' : ''} into chat session!`);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage('Ingestion error: ' + (err.message || String(err)));
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!win.isOpen) {
|
||||||
|
// Render a compact floating pill when closed/hidden
|
||||||
|
return h('div', { className: 'dsh-ctx-overlay-container' },
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-min-badge',
|
||||||
|
onClick: () => updateWindowState({ isOpen: true, isMinimized: false })
|
||||||
|
},
|
||||||
|
h('span', null, '📁'),
|
||||||
|
h('span', null, 'Context Ingester')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (win.isMinimized) {
|
||||||
|
return h('div', { className: 'dsh-ctx-overlay-container' },
|
||||||
|
h('div', {
|
||||||
|
className: 'dsh-ctx-min-badge',
|
||||||
|
onClick: () => updateWindowState({ isMinimized: false })
|
||||||
|
},
|
||||||
|
h('span', null, '📁'),
|
||||||
|
h('span', null, workspacePath ? workspacePath.split('/').pop() || workspacePath : 'Context Ingester'),
|
||||||
|
scanResult ? h('span', { style: { opacity: 0.7 } }, `(${scanResult.files.length} files)`) : null,
|
||||||
|
h('span', { style: { marginLeft: '6px', fontSize: '10px' } }, '▲')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = phase === 'scanning' || phase === 'reading' || phase === 'injecting';
|
||||||
|
const currentPathDisplay = workspacePath || '(auto-detecting...)';
|
||||||
|
|
||||||
|
return h('div', { className: 'dsh-ctx-overlay-container' },
|
||||||
|
h('div', { className: 'dsh-ctx-window' },
|
||||||
|
// Window Header
|
||||||
|
h('div', { className: 'dsh-ctx-header' },
|
||||||
|
h('div', { className: 'dsh-ctx-title' },
|
||||||
|
h('span', null, '📁'),
|
||||||
|
h('span', null, 'Workspace Context Ingestion')
|
||||||
|
),
|
||||||
|
h('div', { style: { display: 'flex', gap: '4px' } },
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-header-btn',
|
||||||
|
title: 'Minimize',
|
||||||
|
onClick: () => updateWindowState({ isMinimized: true })
|
||||||
|
}, '—'),
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-header-btn',
|
||||||
|
title: 'Close',
|
||||||
|
onClick: () => updateWindowState({ isOpen: false })
|
||||||
|
}, '✕')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Window Body
|
||||||
|
h('div', { className: 'dsh-ctx-body' },
|
||||||
|
// Workspace Path Row
|
||||||
|
h('div', { className: 'dsh-ctx-field' },
|
||||||
|
h('div', { className: 'dsh-ctx-label' }, 'Workspace Directory:'),
|
||||||
|
h('div', { className: 'dsh-ctx-input-row' },
|
||||||
|
h('input', {
|
||||||
|
className: 'dsh-ctx-input',
|
||||||
|
type: 'text',
|
||||||
|
value: workspacePath,
|
||||||
|
placeholder: '/path/to/workspace',
|
||||||
|
disabled: isBusy,
|
||||||
|
onChange: (e) => setWorkspacePath(e.target.value)
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-btn',
|
||||||
|
disabled: isBusy || !workspacePath.trim(),
|
||||||
|
onClick: handleScan
|
||||||
|
}, phase === 'scanning' ? h('span', { className: 'dsh-ctx-spinner' }) : 'Scan')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Header Link Protocol Switch
|
||||||
|
h('div', { className: 'dsh-ctx-switch-row' },
|
||||||
|
h('span', { style: { fontWeight: 500 } }, 'Header Link Protocol:'),
|
||||||
|
h('div', { className: 'dsh-ctx-switch-pills' },
|
||||||
|
h('button', {
|
||||||
|
className: `dsh-ctx-pill ${scheme === 'file' ? 'active' : ''}`,
|
||||||
|
disabled: isBusy,
|
||||||
|
onClick: () => setScheme('file')
|
||||||
|
}, 'file:///'),
|
||||||
|
h('button', {
|
||||||
|
className: `dsh-ctx-pill ${scheme === 'vscode' ? 'active' : ''}`,
|
||||||
|
disabled: isBusy,
|
||||||
|
onClick: () => setScheme('vscode')
|
||||||
|
}, 'vscode://file/')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Ignore Rules Badges
|
||||||
|
h('div', { className: 'dsh-ctx-badges' },
|
||||||
|
h('span', { className: 'dsh-ctx-badge' }, '🚫 .git/ (Always ignored)'),
|
||||||
|
h('span', { className: `dsh-ctx-badge ${scanResult?.hasGitignore ? 'found' : ''}` },
|
||||||
|
scanResult?.hasGitignore ? '✓ .gitignore (Active)' : '○ .gitignore'
|
||||||
|
),
|
||||||
|
h('span', { className: `dsh-ctx-badge ${scanResult?.hasDshignore ? 'found' : ''}` },
|
||||||
|
scanResult?.hasDshignore ? '✓ .dshignore (Active)' : '○ .dshignore'
|
||||||
|
),
|
||||||
|
scanResult?.ignoredCount ? h('span', { className: 'dsh-ctx-badge' }, `${scanResult.ignoredCount} files ignored`) : null
|
||||||
|
),
|
||||||
|
|
||||||
|
// Scanned File List (if scanned)
|
||||||
|
scanResult && h('div', { className: 'dsh-ctx-field' },
|
||||||
|
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
||||||
|
h('div', { className: 'dsh-ctx-label' }, `Files to Ingest (${selectedFiles.size}/${scanResult.files.length}):`),
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-header-btn',
|
||||||
|
style: { fontSize: '11px', textDecoration: 'underline' },
|
||||||
|
disabled: isBusy,
|
||||||
|
onClick: toggleAll
|
||||||
|
}, selectedFiles.size === scanResult.files.length ? 'Deselect All' : 'Select All')
|
||||||
|
),
|
||||||
|
h('div', { className: 'dsh-ctx-file-list' },
|
||||||
|
scanResult.files.map(f => {
|
||||||
|
const isSelected = selectedFiles.has(f.relativePath);
|
||||||
|
return h('div', {
|
||||||
|
key: f.relativePath,
|
||||||
|
className: 'dsh-ctx-file-item',
|
||||||
|
onClick: () => !isBusy && toggleFile(f.relativePath)
|
||||||
|
},
|
||||||
|
h('div', { className: 'dsh-ctx-file-info' },
|
||||||
|
h('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: isSelected,
|
||||||
|
disabled: isBusy,
|
||||||
|
onChange: () => {}
|
||||||
|
}),
|
||||||
|
h('span', null, f.relativePath)
|
||||||
|
),
|
||||||
|
h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
||||||
|
h('span', { className: `dsh-ctx-file-tag ${f.isBinaryByExt ? 'bin' : ''}` },
|
||||||
|
f.isBinaryByExt ? 'binary' : (f.extension || 'txt')
|
||||||
|
),
|
||||||
|
h('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '10px' } },
|
||||||
|
f.size > 1024 ? `${(f.size / 1024).toFixed(1)}k` : `${f.size}b`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress Bar & Status (when reading/injecting)
|
||||||
|
isBusy && h('div', { className: 'dsh-ctx-progress' },
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px' } },
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
||||||
|
h('span', { className: 'dsh-ctx-spinner' }),
|
||||||
|
h('span', { style: { fontWeight: 500 } }, progress.phaseText || 'Processing...')
|
||||||
|
),
|
||||||
|
progress.total > 0 && h('span', { style: { opacity: 0.8 } },
|
||||||
|
`${Math.round((progress.current / progress.total) * 100)}%`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
progress.total > 0 && h('div', { className: 'dsh-ctx-progress-bar-bg' },
|
||||||
|
h('div', {
|
||||||
|
className: 'dsh-ctx-progress-bar-fill',
|
||||||
|
style: { width: `${(progress.current / progress.total) * 100}%` }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error Notice
|
||||||
|
errorMessage && h('div', { className: 'dsh-ctx-notice-error' }, errorMessage),
|
||||||
|
|
||||||
|
// Success Notice
|
||||||
|
successMessage && h('div', { className: 'dsh-ctx-notice-success' }, successMessage),
|
||||||
|
|
||||||
|
// Primary Action Button showing workspace path
|
||||||
|
h('button', {
|
||||||
|
className: 'dsh-ctx-btn dsh-ctx-btn-primary',
|
||||||
|
disabled: isBusy || !workspacePath.trim(),
|
||||||
|
onClick: handleIngest
|
||||||
|
},
|
||||||
|
isBusy ? [
|
||||||
|
h('span', { key: 'sp', className: 'dsh-ctx-spinner' }),
|
||||||
|
h('span', { key: 'tx' }, progress.phaseText || 'Processing...')
|
||||||
|
] : [
|
||||||
|
h('span', { key: 'ic' }, '📥'),
|
||||||
|
h('span', { key: 'lb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
||||||
|
`Ingest: ${currentPathDisplay}`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header Action Button mounted in conversation.session.header.actions
|
||||||
|
function HeaderActionButton(props) {
|
||||||
|
return h('button', {
|
||||||
|
className: 'dsh-ctx-btn',
|
||||||
|
style: {
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
gap: '6px'
|
||||||
|
},
|
||||||
|
title: 'Open Workspace Context Ingestion Window',
|
||||||
|
onClick: () => {
|
||||||
|
updateWindowState({ isOpen: true, isMinimized: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
h('span', null, '📁'),
|
||||||
|
h('span', null, 'Context Ingester')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register components in Cordis slots
|
||||||
|
slots.inject('shell.overlay', () => slots.register(
|
||||||
|
{ name: 'shell.overlay', id: 'workspace-context-overlay' },
|
||||||
|
ContextOverlayWindow
|
||||||
|
));
|
||||||
|
|
||||||
|
slots.inject('conversation.session.header.actions', () => slots.register(
|
||||||
|
{ name: 'conversation.session.header.actions', id: 'workspace-context-action', order: 15 },
|
||||||
|
HeaderActionButton
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = clientPlugin;
|
||||||
278
src/host.js
Normal file
278
src/host.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek Harness Context Plugin - Host Implementation
|
||||||
|
*
|
||||||
|
* Provides RPC endpoints for:
|
||||||
|
* - Detecting workspace root directory
|
||||||
|
* - Scanning files respecting .gitignore, .dshignore, and .git rules
|
||||||
|
* - Reading text/binary file contents with dynamic backtick code fences
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hostPlugin() {
|
||||||
|
return {
|
||||||
|
apply(ctx) {
|
||||||
|
const fs = ctx.get('fs');
|
||||||
|
const sandboxPolicy = ctx.get('sandboxPolicy');
|
||||||
|
|
||||||
|
// Helper: get current workspace root
|
||||||
|
harness.handle('get-workspace-root', async () => {
|
||||||
|
try {
|
||||||
|
const root = sandboxPolicy?.workspaceRoot || '';
|
||||||
|
return { workspaceRoot: root };
|
||||||
|
} catch (e) {
|
||||||
|
return { workspaceRoot: '' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: parse gitignore / dshignore rules
|
||||||
|
function parseIgnoreRules(text) {
|
||||||
|
if (!text || typeof text !== 'string') return [];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const rules = [];
|
||||||
|
for (let rawLine of lines) {
|
||||||
|
let line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
let negated = false;
|
||||||
|
if (line.startsWith('!')) {
|
||||||
|
negated = true;
|
||||||
|
line = line.slice(1).trim();
|
||||||
|
}
|
||||||
|
if (!line) continue;
|
||||||
|
const dirOnly = line.endsWith('/');
|
||||||
|
if (dirOnly) line = line.slice(0, -1);
|
||||||
|
if (!line) continue;
|
||||||
|
const hasSlash = line.includes('/');
|
||||||
|
let pattern = line;
|
||||||
|
if (pattern.startsWith('/')) pattern = pattern.slice(1);
|
||||||
|
|
||||||
|
// Convert glob pattern to regular expression
|
||||||
|
let re = '';
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const c = pattern[i];
|
||||||
|
if (c === '*' && pattern[i + 1] === '*') {
|
||||||
|
if (pattern[i + 2] === '/') {
|
||||||
|
re += '(?:.*/)?';
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
re += '.*';
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else if (c === '*') {
|
||||||
|
re += '[^/]*';
|
||||||
|
} else if (c === '?') {
|
||||||
|
re += '[^/]';
|
||||||
|
} else if ('[\\]{}()+^$|.,'.includes(c)) {
|
||||||
|
re += '\\' + c;
|
||||||
|
} else {
|
||||||
|
re += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex;
|
||||||
|
if (hasSlash) {
|
||||||
|
regex = new RegExp('^' + re + '(?:/.*)?$');
|
||||||
|
} else {
|
||||||
|
regex = new RegExp('(?:^|/)' + re + '(?:/.*)?$');
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.push({ raw: rawLine, pattern, negated, dirOnly, regex });
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathIgnored(relPath, isDir, rules) {
|
||||||
|
// 1. Always ignore .git/ at any depth
|
||||||
|
const parts = relPath.split('/');
|
||||||
|
if (parts.includes('.git')) return true;
|
||||||
|
|
||||||
|
// 2. Evaluate ignore rules in order
|
||||||
|
let ignored = false;
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.dirOnly && !isDir) continue;
|
||||||
|
if (rule.regex.test(relPath)) {
|
||||||
|
ignored = !rule.negated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
|
||||||
|
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma',
|
||||||
|
'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm',
|
||||||
|
'zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'iso',
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||||
|
'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'obj', 'class', 'pyc', 'pyo', 'wasm',
|
||||||
|
'woff', 'woff2', 'ttf', 'eot', 'otf',
|
||||||
|
'db', 'sqlite', 'sqlite3'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getExt(filename) {
|
||||||
|
const idx = filename.lastIndexOf('.');
|
||||||
|
if (idx <= 0) return '';
|
||||||
|
return filename.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint: scan-workspace
|
||||||
|
harness.handle('scan-workspace', async (args) => {
|
||||||
|
if (!fs) throw new Error('fs service is not available on Host');
|
||||||
|
const rootPath = args?.workspacePath || sandboxPolicy?.workspaceRoot || '.';
|
||||||
|
const rootTarget = await fs.resolve(rootPath);
|
||||||
|
const canonicalRoot = fs.processPath(rootTarget);
|
||||||
|
|
||||||
|
// Read .gitignore in workspace root
|
||||||
|
let gitignoreContent = '';
|
||||||
|
let hasGitignore = false;
|
||||||
|
try {
|
||||||
|
const giTarget = await fs.resolve('.gitignore', { cwd: canonicalRoot });
|
||||||
|
const giStat = await fs.stat(giTarget);
|
||||||
|
if (giStat && giStat.type === 'file') {
|
||||||
|
gitignoreContent = await fs.readText(giTarget);
|
||||||
|
hasGitignore = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Read .dshignore in workspace root
|
||||||
|
let dshignoreContent = '';
|
||||||
|
let hasDshignore = false;
|
||||||
|
try {
|
||||||
|
const diTarget = await fs.resolve('.dshignore', { cwd: canonicalRoot });
|
||||||
|
const diStat = await fs.stat(diTarget);
|
||||||
|
if (diStat && diStat.type === 'file') {
|
||||||
|
dshignoreContent = await fs.readText(diTarget);
|
||||||
|
hasDshignore = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const rules = [
|
||||||
|
...parseIgnoreRules(gitignoreContent),
|
||||||
|
...parseIgnoreRules(dshignoreContent)
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
let ignoredCount = 0;
|
||||||
|
const queue = [{ target: rootTarget, relDir: '' }];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift();
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.listDir(item.target);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = entry.name;
|
||||||
|
const relPath = item.relDir ? (item.relDir + '/' + name) : name;
|
||||||
|
const isDir = entry.type === 'directory';
|
||||||
|
|
||||||
|
if (isPathIgnored(relPath, isDir, rules)) {
|
||||||
|
ignoredCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
queue.push({ target: entry.target, relDir: relPath });
|
||||||
|
} else if (entry.type === 'file') {
|
||||||
|
const absPath = fs.processPath(entry.target);
|
||||||
|
const ext = getExt(name);
|
||||||
|
const isBin = BINARY_EXTENSIONS.has(ext);
|
||||||
|
files.push({
|
||||||
|
relativePath: relPath,
|
||||||
|
absolutePath: absPath,
|
||||||
|
extension: ext,
|
||||||
|
size: entry.size || 0,
|
||||||
|
isBinaryByExt: isBin
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootPath: canonicalRoot,
|
||||||
|
hasGitignore,
|
||||||
|
hasDshignore,
|
||||||
|
ignoredCount,
|
||||||
|
files
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: calculate dynamic backtick fence (>= 3 backticks, exceeding any inner sequence)
|
||||||
|
function getBacktickFence(str) {
|
||||||
|
if (!str) return '```';
|
||||||
|
const matches = str.match(/`+/g);
|
||||||
|
if (!matches) return '```';
|
||||||
|
let maxLen = 0;
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m.length > maxLen) maxLen = m.length;
|
||||||
|
}
|
||||||
|
return '`'.repeat(Math.max(3, maxLen + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint: read-file
|
||||||
|
harness.handle('read-file', async (args) => {
|
||||||
|
if (!fs) throw new Error('fs service is not available on Host');
|
||||||
|
const { absolutePath, relativePath, isBinaryByExt, extension } = args;
|
||||||
|
|
||||||
|
if (isBinaryByExt) {
|
||||||
|
return {
|
||||||
|
relativePath,
|
||||||
|
absolutePath,
|
||||||
|
extension: extension || '',
|
||||||
|
content: '*Binary file - content not displayed.*',
|
||||||
|
isBinary: true,
|
||||||
|
fence: '```'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = await fs.resolve(absolutePath);
|
||||||
|
// Null-byte check on first 8KB of bytes
|
||||||
|
const bytes = await fs.readBytes(target, undefined, 8192);
|
||||||
|
let hasNull = false;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (bytes[i] === 0) {
|
||||||
|
hasNull = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNull) {
|
||||||
|
return {
|
||||||
|
relativePath,
|
||||||
|
absolutePath,
|
||||||
|
extension: extension || '',
|
||||||
|
content: '*Binary file - content not displayed.*',
|
||||||
|
isBinary: true,
|
||||||
|
fence: '```'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await fs.readText(target);
|
||||||
|
const fence = getBacktickFence(text);
|
||||||
|
return {
|
||||||
|
relativePath,
|
||||||
|
absolutePath,
|
||||||
|
extension: extension || '',
|
||||||
|
content: text,
|
||||||
|
isBinary: false,
|
||||||
|
fence
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
relativePath,
|
||||||
|
absolutePath,
|
||||||
|
extension: extension || '',
|
||||||
|
content: '*Binary file - content not displayed.*',
|
||||||
|
isBinary: true,
|
||||||
|
fence: '```'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = hostPlugin;
|
||||||
Reference in New Issue
Block a user