Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ vp run dev

## Modes / Modos

## Product Boundaries / Límites del producto

Code Club IDE is intentionally split into three bounded modes, not one mixed surface. Coding Mode is the core IDE, Studio Mode is a local table-based project workspace, and Design Mode is a lightweight vector design tool. Each mode has its own data model, workflow, and responsibility.

Code Club IDE está dividido intencionalmente en tres modos con límites claros, no en una sola superficie mezclada. Modo Código es el IDE principal, Modo Studio es un espacio local de gestión basado en tablas, y Modo Diseño es una herramienta liviana de diseño vectorial. Cada modo tiene su propio modelo de datos, flujo de trabajo y responsabilidad.

**Coding Mode / Modo Código**
Standard IDE. File explorer, Monaco editor (same engine as VS Code), integrated terminal (PowerShell, WSL, Git Bash), AI agent panel. Multi-chat sessions, sandbox safety mode, checkpoints with rollback, split layouts (single, 2-col, 4-quadrant).

Expand Down Expand Up @@ -98,6 +104,14 @@ Todo el tráfico de IA va directo de tu dispositivo al proveedor. codeclub no pr
| 7 | The debugging takes as long as it has to take. | El debugging durará lo que tenga que durar. |
| 8 | First night at Code Club? Open the editor and tame the silicon beast. | ¿Primera noche en el Code Club? Abrís el editor y domás a la bestia de silicio. |

## Contributors / Colaboradores

Thanks to everyone helping build Code Club IDE. / Gracias a todas las personas que ayudan a construir Code Club IDE.

<a href="https://github.com/Matecore-repo/CodeClubIDE/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Matecore-repo/CodeClubIDE" alt="Contributors / Colaboradores" />
</a>

## Licensing / Licencia

| License / Licencia | Use Case / Caso de Uso | Cost / Costo |
Expand Down
16 changes: 10 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
"vite-plus": "latest"
},
"overrides": {
"dompurify": "$dompurify"
"dompurify": "$dompurify",
"expr-eval": "npm:expr-eval-fork@^3.0.1"
},
"devEngines": {
"packageManager": {
Expand Down
7 changes: 6 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { appendFileSync, mkdirSync, existsSync } from "fs";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { registerIpcHandlers } from "./ipc";
import { cleanupTerminals } from "./ipc/terminal";
import { ipcWarn, normalizeIpcUrl } from "./ipc/validation";

function getLogPath(): string {
const logsDir = is.dev ? join(__dirname, "../../logs") : join(app.getPath("userData"), "logs");
Expand Down Expand Up @@ -68,7 +69,11 @@ function createWindow(): void {
// });

mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
try {
shell.openExternal(normalizeIpcUrl(details.url, ["http:", "https:", "mailto:"]));
} catch (error) {
ipcWarn("window:openExternal", error);
}
return { action: "deny" };
});

Expand Down
47 changes: 40 additions & 7 deletions src/main/indexer/scanner.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,73 @@
import { EventEmitter } from "events";
import { beforeEach, describe, expect, it } from "vite-plus/test";
import { vi } from "vitest";

const execFile = vi.fn();
const spawn = vi.fn();
const existsSync = vi.fn();
vi.mock("electron", () => ({
app: { isPackaged: false },
}));
vi.mock("child_process", () => ({ execFile }));
vi.mock("child_process", () => ({ execFile, spawn }));
vi.mock("fs", () => ({ existsSync }));

const { scanWorkspace, searchHybrid } = await import("./scanner");

function mockSpawn(stdoutChunks: string[], exitCode = 0): void {
spawn.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.kill = vi.fn(() => true);

queueMicrotask(() => {
for (const chunk of stdoutChunks) child.stdout.emit("data", chunk);
child.emit("close", exitCode);
});

return child;
});
}

describe("Rust scanner fallback", () => {
beforeEach(() => vi.clearAllMocks());

it("returns null when the engine is absent", async () => {
existsSync.mockReturnValue(false);
expect(await scanWorkspace("C:\\workspace")).toBeNull();
expect(spawn).not.toHaveBeenCalled();
});

it("returns null when scan output is invalid", async () => {
existsSync.mockReturnValue(true);
execFile.mockImplementation((_file, _args, _options, callback) =>
callback(null, "not-json\n", ""),
);
mockSpawn(["not-json\n"]);
expect(await scanWorkspace("C:\\workspace")).toBeNull();
});

it("parses JSONL chunks", async () => {
existsSync.mockReturnValue(true);
execFile.mockImplementation((_file, _args, _options, callback) =>
callback(null, '{"id":"1","filePath":"a.ts","startLine":1,"endLine":1,"code":"x"}\n', ""),
);
mockSpawn(['{"id":"1","filePath":"a.ts","startLine":1,"endLine":1,"code":"x"}\n']);
expect(await scanWorkspace("C:\\workspace")).toHaveLength(1);
});

it("returns null when the scan process exits unsuccessfully", async () => {
existsSync.mockReturnValue(true);
mockSpawn([], 1);
expect(await scanWorkspace("C:\\workspace")).toBeNull();
});

it("parses JSONL chunks split across stdout events", async () => {
existsSync.mockReturnValue(true);
mockSpawn([
'{"id":"1","filePath":"a.ts",',
'"startLine":1,"endLine":1,"code":"x"}\n',
'{"id":"2","filePath":"b.ts","startLine":2,"endLine":2,"code":"y"}',
]);
expect(await scanWorkspace("C:\\workspace")).toHaveLength(2);
});

it("falls back when hybrid search process fails", async () => {
existsSync.mockReturnValue(true);
execFile.mockImplementation((_file, _args, _options, callback) =>
Expand Down
69 changes: 56 additions & 13 deletions src/main/indexer/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app } from "electron";
import { execFile } from "child_process";
import { execFile, spawn } from "child_process";
import { existsSync } from "fs";
import { join } from "path";
import type { IndexChunk, SearchResult } from "./types";
Expand Down Expand Up @@ -28,23 +28,66 @@ export async function scanWorkspace(
return new Promise((resolve) => {
const args = ["scan", workspacePath];
if (cachePath) args.push(cachePath);
execFile(binaryPath, args, { maxBuffer: 100 * 1024 * 1024 }, (error, stdout) => {
if (error) {
console.warn("[indexer] Rust scanner failed, using TypeScript fallback:", error);
resolve(null);
return;
}

const chunks: IndexChunk[] = [];
let buffer = "";
let settled = false;

const finish = (result: IndexChunk[] | null): void => {
if (settled) return;
settled = true;
resolve(result);
};

const parseLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed) return true;
try {
const chunks = stdout
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as IndexChunk);
resolve(chunks);
chunks.push(JSON.parse(trimmed) as IndexChunk);
return true;
} catch (error) {
console.warn("[indexer] Invalid Rust scanner output, using TypeScript fallback:", error);
resolve(null);
return false;
}
};

const child = spawn(binaryPath, args, { windowsHide: true });

child.stdout.on("data", (data: Buffer | string) => {
if (settled) return;
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";

for (const line of lines) {
if (!parseLine(line)) {
child.kill();
finish(null);
return;
}
}
});

child.on("error", (error) => {
console.warn("[indexer] Rust scanner failed, using TypeScript fallback:", error);
finish(null);
});

child.on("close", (code) => {
if (settled) return;
if (code !== 0) {
const error = new Error(`Rust scanner exited with code ${code ?? "unknown"}`);
console.warn("[indexer] Rust scanner failed, using TypeScript fallback:", error);
finish(null);
return;
}

if (buffer && !parseLine(buffer)) {
finish(null);
return;
}

finish(chunks);
});
});
}
Expand Down
44 changes: 25 additions & 19 deletions src/main/ipc/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
readTopographicContent,
} from "./fs/topographic";
import { ensureTopographicCache } from "./fs/graphCache";
import { ipcWarn, normalizeIpcPath } from "./validation";

function decomposeFileToSections(filePath: string, content: string): StructuralNode[] {
const lines = content.split("\n");
Expand Down Expand Up @@ -258,28 +259,27 @@ export function registerFsHandlers(): void {
ipcMain.handle("fs:readFileBase64", (_event, filePath: string) => {
let finalPath = filePath;
try {
if (typeof filePath !== "string" || !filePath.trim() || filePath.includes("\0")) return null;
if (filePath.startsWith("resources/") || filePath.startsWith("resources\\")) {
finalPath = app.isPackaged
? join(process.resourcesPath, filePath)
: join(process.cwd(), filePath);
} else {
finalPath = normalizeIpcPath(filePath, "filePath");
}
return readFileSync(finalPath).toString("base64");
} catch (e: any) {
try {
require("fs").appendFileSync(
"C:\\Users\\iange\\codeclubDebug.txt",
finalPath + " -> " + e.message + "\n",
);
} catch {}
ipcWarn("fs:readFileBase64", e);
return null;
}
});

ipcMain.handle("fs:copyFile", (_event, src: string, dest: string) => {
try {
cpSync(src, dest, { recursive: true });
cpSync(normalizeIpcPath(src, "src"), normalizeIpcPath(dest, "dest"), { recursive: true });
return true;
} catch {
} catch (error) {
ipcWarn("fs:copyFile", error);
return false;
}
});
Expand Down Expand Up @@ -447,44 +447,49 @@ export function registerFsHandlers(): void {

ipcMain.handle("fs:createFile", (_event, filePath: string) => {
try {
writeFileSync(filePath, "", "utf-8");
writeFileSync(normalizeIpcPath(filePath, "filePath"), "", "utf-8");
return true;
} catch {
} catch (error) {
ipcWarn("fs:createFile", error);
return false;
}
});

ipcMain.handle("fs:createDir", (_event, dirPath: string) => {
try {
mkdirSync(dirPath, { recursive: true });
mkdirSync(normalizeIpcPath(dirPath, "dirPath"), { recursive: true });
return true;
} catch {
} catch (error) {
ipcWarn("fs:createDir", error);
return false;
}
});

ipcMain.handle("fs:rename", (_event, oldPath: string, newPath: string) => {
try {
renameSync(oldPath, newPath);
renameSync(normalizeIpcPath(oldPath, "oldPath"), normalizeIpcPath(newPath, "newPath"));
return true;
} catch {
} catch (error) {
ipcWarn("fs:rename", error);
return false;
}
});

ipcMain.handle("fs:delete", async (_event, targetPath: string) => {
try {
const safeTarget = normalizeIpcPath(targetPath, "targetPath");
const binaryPath = getScanBinaryPath();
if (!existsSync(binaryPath)) {
rmSync(targetPath, { recursive: true, force: true });
rmSync(safeTarget, { recursive: true, force: true });
return true;
}
return new Promise<boolean>((resolve) => {
execFile(binaryPath, ["io", "delete-file", targetPath], (error) => {
execFile(binaryPath, ["io", "delete-file", safeTarget], (error) => {
resolve(!error);
});
});
} catch {
} catch (error) {
ipcWarn("fs:delete", error);
return false;
}
});
Expand Down Expand Up @@ -528,8 +533,9 @@ export function registerFsHandlers(): void {

ipcMain.handle("fs:exists", (_event, targetPath: string) => {
try {
return existsSync(targetPath);
} catch {
return existsSync(normalizeIpcPath(targetPath, "targetPath"));
} catch (error) {
ipcWarn("fs:exists", error);
return false;
}
});
Expand Down
Loading
Loading