87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/python_backend/controllers/metaprompts_controller.py:
--------------------------------------------------------------------------------
1 | # python_backend/controllers/metaprompts_controller.py
2 | import logging
3 | from flask import Blueprint, request
4 | from pydantic import ValidationError
5 |
6 | from services.metaprompt_service import MetapromptService
7 | from repositories.file_storage import FileStorageRepository
8 | from utils.response_utils import success_response, error_response
9 | from models.request_models import SaveMetapromptRequest # NEW
10 |
11 | logger = logging.getLogger(__name__)
12 | metaprompts_blueprint = Blueprint("metaprompts_blueprint", __name__)
13 |
14 | # ─── dependencies ───────────────────────────────────────────────────────────
15 | _storage = FileStorageRepository()
16 | _metaprompt = MetapromptService(storage_repo=_storage)
17 | # ────────────────────────────────────────────────────────────────────────────
18 |
19 |
20 | @metaprompts_blueprint.get("/api/metaprompts")
21 | def list_or_load_metaprompt():
22 | action = request.args.get("action", "").strip().lower()
23 | dir_param = request.args.get("dir")
24 |
25 | if action == "list":
26 | try:
27 | files = _metaprompt.list_metaprompts(dir_param)
28 | return success_response(data=files)
29 | except IOError as exc:
30 | return error_response(str(exc), "Cannot access meta prompts directory", 500)
31 | except Exception as exc:
32 | logger.exception("Error listing meta prompts")
33 | return error_response(str(exc), "Failed to list meta prompts", 500)
34 |
35 | if action == "load":
36 | filename = request.args.get("file", "").strip()
37 | if not filename:
38 | return error_response("Missing 'file' query parameter for load action.", 400)
39 | try:
40 | content = _metaprompt.load_metaprompt(filename, dir_param)
41 | if content is None:
42 | return error_response(f"Meta prompt file '{filename}' not found.", 404)
43 | return success_response(data={"content": content})
44 | except IOError as exc:
45 | return error_response(str(exc), "Cannot access meta prompts directory", 500)
46 | except Exception as exc:
47 | logger.exception("Error loading meta prompt: %s", filename)
48 | return error_response(str(exc), f"Failed to load meta prompt: {filename}", 500)
49 |
50 | return error_response(f"Invalid action '{action}'. Use 'list' or 'load'.", 400)
51 |
52 |
53 | @metaprompts_blueprint.post("/api/metaprompts")
54 | def save_metaprompt_endpoint():
55 | dir_param = request.args.get("dir")
56 | payload = request.get_json(silent=True) or {}
57 | try:
58 | req = SaveMetapromptRequest(**payload)
59 | except ValidationError as exc:
60 | return error_response(f"Validation error: {exc.errors()}", 400)
61 |
62 | try:
63 | _metaprompt.save_metaprompt(req.filename, req.content, dir_param)
64 | saved_name = req.filename if req.filename.lower().endswith(".txt") else f"{req.filename}.txt"
65 | return success_response(message=f"Meta prompt '{saved_name}' saved successfully.")
66 | except ValueError as exc:
67 | return error_response(str(exc), 400)
68 | except IOError as exc:
69 | logger.error("IOError saving meta prompt %s: %s", req.filename, exc)
70 | return error_response(str(exc), "Failed to save meta prompt file", 500)
71 | except Exception as exc:
72 | logger.exception("Error saving meta prompt: %s", req.filename)
73 | return error_response(str(exc), "Failed to save meta prompt", 500)
74 |
--------------------------------------------------------------------------------
/views/layout/MainLayoutView.tsx:
--------------------------------------------------------------------------------
1 | // File: views/layout/MainLayoutView.tsx
2 | import React from "react";
3 | import LeftPanelView from "./LeftPanelView";
4 | import RightPanelView from "./RightPanelView";
5 | import type { FileNode } from "@/types";
6 | import type { FileTreeViewHandle } from "@/views/FileTreeView";
7 |
8 | interface MainLayoutViewProps {
9 | // Props needed for Left Panel
10 | activeTab: "files" | "options" | "tasks";
11 | setActiveTab: (tab: "files" | "options" | "tasks") => void;
12 | projectPath: string;
13 | isLoadingTree: boolean;
14 | fileSearchTerm: string;
15 | setFileSearchTerm: (term: string) => void;
16 | handleRefresh: () => void;
17 | handleSelectAll: () => void;
18 | deselectAllFiles: () => void;
19 | treeRef: React.RefObject;
20 | filteredTree: FileNode[];
21 | selectedFilePaths: string[];
22 | setSelectedFilePaths: (paths: string[]) => void;
23 | fileTree: FileNode[]; // Needed for SelectionGroupsView
24 |
25 | // Props needed for Right Panel
26 | hasContent: boolean;
27 | selectedFileCount: number;
28 | totalTokens: number;
29 | }
30 |
31 | const MainLayoutView: React.FC = (props) => {
32 | return (
33 |
34 | {/* Main content container with enhanced grid layout */}
35 |
36 | {/* Enhanced decorative background elements */}
37 |
38 |
39 |
40 |
41 | {/* Left Panel - Takes up 2 columns on large screens */}
42 |
63 |
64 | {/* Right Panel - Takes up 1 column on large screens */}
65 |
66 |
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default MainLayoutView;
78 |
--------------------------------------------------------------------------------
/scripts/postinstall.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /**
3 | * scripts/postinstall.js
4 | *
5 | * A cross-platform script that attempts to:
6 | * 1) Locate a working "python" interpreter (python3, python, or py).
7 | * 2) Create/upgrade a virtual environment under python_backend/venv.
8 | * 3) Install packages from python_backend/requirements.txt.
9 | *
10 | * If Python is not found, or anything fails, it will print an error but continue.
11 | * You can modify to `process.exit(1)` if you want a hard failure.
12 | */
13 |
14 | const { spawnSync } = require('child_process');
15 | const fs = require('fs');
16 | const path = require('path');
17 | const os = require('os');
18 |
19 | // 1) Attempt to find a suitable Python command.
20 | function findPythonCommand() {
21 | const candidates = os.platform() === 'win32'
22 | ? ['py', 'python', 'python3']
23 | : ['python3', 'python'];
24 |
25 | for (const cmd of candidates) {
26 | const result = spawnSync(cmd, ['--version'], { encoding: 'utf8' });
27 | if (result.status === 0 && !result.error) {
28 | return cmd;
29 | }
30 | }
31 | return null;
32 | }
33 |
34 | const pythonCmd = findPythonCommand();
35 | if (!pythonCmd) {
36 | console.warn(`
37 | [postinstall] WARNING: Could not find a working Python interpreter.
38 | [postinstall] Skipping virtual environment creation and Python deps install.
39 | `);
40 | process.exit(0); // or `process.exit(1)` if you want to fail hard
41 | }
42 |
43 | // 2) Create or upgrade the venv inside python_backend/venv
44 | const backendDir = path.join(__dirname, '..', 'python_backend');
45 | const venvDir = path.join(backendDir, 'venv');
46 |
47 | // Check if venv directory exists
48 | if (!fs.existsSync(venvDir)) {
49 | console.log(`[postinstall] Creating virtual environment with "${pythonCmd}"...`);
50 | const venvResult = spawnSync(pythonCmd, ['-m', 'venv', 'venv'], {
51 | cwd: backendDir,
52 | stdio: 'inherit',
53 | });
54 | if (venvResult.status !== 0) {
55 | console.error('[postinstall] ERROR: Failed to create virtual environment.');
56 | process.exit(0); // or `process.exit(1)`
57 | }
58 | } else {
59 | console.log('[postinstall] Virtual environment already exists. Skipping creation.');
60 | }
61 |
62 | // 3) Install the Python dependencies from requirements.txt inside the venv
63 | // We need to call the pip inside the venv.
64 | // On Windows: venv\\Scripts\\pip.exe
65 | // On Mac/Linux: venv/bin/pip
66 | let pipPath = '';
67 | if (os.platform() === 'win32') {
68 | pipPath = path.join(venvDir, 'Scripts', 'pip.exe');
69 | } else {
70 | pipPath = path.join(venvDir, 'bin', 'pip');
71 | }
72 |
73 | // If pip doesn't exist yet, fallback to: python -m pip ...
74 | if (!fs.existsSync(pipPath)) {
75 | console.log('[postinstall] pip not found in venv; trying python -m pip...');
76 | pipPath = pythonCmd;
77 | }
78 |
79 | const reqFile = path.join(backendDir, 'requirements.txt');
80 | if (!fs.existsSync(reqFile)) {
81 | console.warn('[postinstall] No requirements.txt found, skipping pip install.');
82 | process.exit(0);
83 | }
84 |
85 | console.log('[postinstall] Installing Python dependencies from requirements.txt...');
86 | const pipResult = spawnSync(
87 | pipPath,
88 | pipPath === pythonCmd
89 | ? ['-m', 'pip', 'install', '-r', 'requirements.txt']
90 | : ['install', '-r', 'requirements.txt'],
91 | {
92 | cwd: backendDir,
93 | stdio: 'inherit',
94 | }
95 | );
96 |
97 | if (pipResult.status !== 0) {
98 | console.error('[postinstall] ERROR: Failed to install Python dependencies.');
99 | process.exit(0); // or `process.exit(1)`
100 | }
101 |
102 | console.log('[postinstall] Python dependencies installed successfully!');
103 | process.exit(0);
104 |
--------------------------------------------------------------------------------
/python_backend/services/metaprompt_service.py:
--------------------------------------------------------------------------------
1 | # File: python_backend/services/metaprompt_service.py
2 | # NEW FILE
3 | import os
4 | import logging
5 | from typing import List, Optional
6 | from services.service_exceptions import wrap_service_methods
7 | from repositories.file_storage import FileStorageRepository
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 |
13 | @wrap_service_methods
14 | class MetapromptService:
15 | """Service layer for managing meta prompt files."""
16 |
17 | def __init__(self, storage_repo: FileStorageRepository):
18 | self.storage_repo = storage_repo
19 | # Default directory relative to project root (adjust if needed)
20 | self.default_dir_relative = os.path.join('sample_project', 'meta_prompts')
21 | self.project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
22 |
23 | def _get_base_dir(self, dir_param: Optional[str]) -> str:
24 | """Determines the base directory for meta prompts."""
25 | if dir_param:
26 | base_dir = os.path.abspath(dir_param)
27 | else:
28 | base_dir = os.path.join(self.project_root, self.default_dir_relative)
29 |
30 | # Ensure the directory exists
31 | try:
32 | os.makedirs(base_dir, exist_ok=True)
33 | except OSError as e:
34 | logger.error(f"Failed to create meta prompts directory {base_dir}: {e}")
35 | raise IOError(f"Cannot access or create meta prompts directory: {base_dir}") from e
36 | return base_dir
37 |
38 | def list_metaprompts(self, dir_param: Optional[str]) -> List[str]:
39 | """Lists all .txt files in the meta prompt directory."""
40 | base_dir = self._get_base_dir(dir_param)
41 | try:
42 | return self.storage_repo.list_files(base_dir, extension='.txt')
43 | except Exception as e:
44 | logger.error(f"Error listing meta prompts in {base_dir}: {e}")
45 | return []
46 |
47 | def load_metaprompt(self, filename: str, dir_param: Optional[str]) -> Optional[str]:
48 | """Loads the content of a specific meta prompt file."""
49 | if not filename or not filename.lower().endswith('.txt'):
50 | logger.warning(f"Invalid filename for loading meta prompt: {filename}")
51 | return None # Or raise ValueError
52 |
53 | base_dir = self._get_base_dir(dir_param)
54 | file_path = os.path.join(base_dir, filename)
55 |
56 | try:
57 | content = self.storage_repo.read_text(file_path)
58 | if content is None:
59 | logger.warning(f"Meta prompt file not found: {file_path}")
60 | return content
61 | except Exception as e:
62 | logger.error(f"Error loading meta prompt {file_path}: {e}")
63 | return None # Or raise
64 |
65 | def save_metaprompt(self, filename: str, content: str, dir_param: Optional[str]):
66 | """Saves content to a meta prompt file."""
67 | if not filename:
68 | raise ValueError("Filename cannot be empty for saving meta prompt.")
69 | if not isinstance(content, str):
70 | # Or handle potential non-string content appropriately
71 | raise ValueError("Content must be a string.")
72 |
73 | # Ensure .txt extension
74 | if not filename.lower().endswith('.txt'):
75 | filename += '.txt'
76 |
77 | base_dir = self._get_base_dir(dir_param)
78 | file_path = os.path.join(base_dir, filename)
79 |
80 | try:
81 | self.storage_repo.write_text(file_path, content)
82 | logger.info(f"Meta prompt saved successfully to {file_path}")
83 | except IOError as e:
84 | logger.error(f"Failed to save meta prompt to {file_path}: {e}")
85 | raise # Re-raise IOErrors
--------------------------------------------------------------------------------
/stores/useKanbanStore.ts:
--------------------------------------------------------------------------------
1 | // FILE: stores/useKanbanStore.ts – FULL FILE (rewritten)
2 | import { create } from 'zustand';
3 | import { devtools } from 'zustand/middleware';
4 | import { immer } from 'zustand/middleware/immer';
5 | import {
6 | KanbanItem,
7 | KanbanStatus,
8 | KanbanStatusValues,
9 | } from '@/types';
10 |
11 | /* ------------------------------------------------------------------ */
12 | /* Store types */
13 | /* ------------------------------------------------------------------ */
14 | interface KanbanState {
15 | items: KanbanItem[];
16 | isLoading: boolean;
17 | isSaving: boolean;
18 | setLoading: (flag: boolean) => void;
19 | setSaving: (flag: boolean) => void;
20 | setAll: (items: KanbanItem[]) => void;
21 | addItem: (item: KanbanItem) => void;
22 | updateItem: (
23 | id: number,
24 | updates: Partial>
25 | ) => void;
26 | removeItem: (id: number) => void;
27 | moveItem: (
28 | id: number,
29 | toStatus: KanbanStatus,
30 | toIndex?: number
31 | ) => void;
32 | }
33 |
34 | /* ------------------------------------------------------------------ */
35 | /* Helper – calculate the global insert index for a status + position */
36 | /* ------------------------------------------------------------------ */
37 | function findGlobalInsertIndex(
38 | allItems: KanbanItem[],
39 | status: KanbanStatus,
40 | localIndex: number | undefined
41 | ): number {
42 | if (localIndex === undefined) return allItems.length; // append to end
43 | let seen = 0;
44 | for (let i = 0; i < allItems.length; i++) {
45 | if (allItems[i].status === status) {
46 | if (seen === localIndex) return i;
47 | seen++;
48 | }
49 | }
50 | return allItems.length; // fallback – should not happen
51 | }
52 |
53 | /* ------------------------------------------------------------------ */
54 | /* Store implementation */
55 | /* ------------------------------------------------------------------ */
56 | export const useKanbanStore = create()(
57 | devtools(
58 | immer((set) => ({
59 | items: [],
60 | isLoading: false,
61 | isSaving: false,
62 |
63 | /* ---------------- flags ---------------- */
64 | setLoading: (flag) => set({ isLoading: flag }),
65 | setSaving: (flag) => set({ isSaving: flag }),
66 |
67 | /* ---------------- collection setters ---- */
68 | setAll: (items) => set({ items }),
69 | addItem: (item) =>
70 | set((state) => {
71 | state.items.push(item);
72 | }),
73 | updateItem: (id, updates) =>
74 | set((state) => {
75 | const idx = state.items.findIndex((it) => it.id === id);
76 | if (idx !== -1) {
77 | state.items[idx] = { ...state.items[idx], ...updates };
78 | }
79 | }),
80 | removeItem: (id) =>
81 | set((state) => {
82 | state.items = state.items.filter((i) => i.id !== id);
83 | }),
84 |
85 | /* ---------------- drag‑and‑drop move ----- */
86 | moveItem(id, toStatus, toIndex) {
87 | set((state) => {
88 | const fromIdx = state.items.findIndex((i) => i.id === id);
89 | if (fromIdx === -1) return; // item not found – defensive guard
90 |
91 | // 1️⃣ Detach the item
92 | const [item] = state.items.splice(fromIdx, 1);
93 | if (!item) return;
94 |
95 | // 2️⃣ Mutate its status
96 | item.status = toStatus;
97 |
98 | // 3️⃣ Compute *global* insertion position so Immer splice is easy
99 | const insertIdx = findGlobalInsertIndex(
100 | state.items,
101 | toStatus,
102 | toIndex
103 | );
104 |
105 | // 4️⃣ Insert at the calculated position
106 | state.items.splice(insertIdx, 0, item);
107 | });
108 | },
109 | }))
110 | )
111 | );
112 |
--------------------------------------------------------------------------------
/python_backend/controllers/todo_controller.py:
--------------------------------------------------------------------------------
1 | # python_backend/controllers/todo_controller.py
2 | import logging
3 | import os
4 | from flask import Blueprint, request
5 | from pydantic import ValidationError
6 |
7 | from services.todo_service import TodoService
8 | from repositories.file_storage import FileStorageRepository
9 | from utils.response_utils import success_response, error_response
10 | from models.request_models import TodoCreateRequest, TodoUpdateRequest # NEW
11 |
12 | logger = logging.getLogger(__name__)
13 | todo_blueprint = Blueprint("todo_blueprint", __name__)
14 |
15 | # ─── dependencies ───────────────────────────────────────────────────────────
16 | _storage = FileStorageRepository()
17 | _todo_s = TodoService(storage_repo=_storage)
18 | # ────────────────────────────────────────────────────────────────────────────
19 |
20 |
21 | @todo_blueprint.get("/api/todos")
22 | def list_todos_endpoint():
23 | project_path = request.args.get("projectPath")
24 | try:
25 | items = _todo_s.list_todos(project_path)
26 | return success_response(data=items)
27 | except Exception as exc:
28 | logger.exception("Error listing todos for project: %s", project_path)
29 | return error_response(str(exc), "Failed to list todos", 500)
30 |
31 |
32 | @todo_blueprint.post("/api/todos")
33 | def add_todo_endpoint():
34 | project_path = request.args.get("projectPath")
35 | payload = request.get_json(silent=True) or {}
36 | try:
37 | req = TodoCreateRequest(**payload)
38 | except ValidationError as exc:
39 | return error_response(f"Validation error: {exc.errors()}", 400)
40 |
41 | try:
42 | new_item = _todo_s.add_todo(req.text.strip(), project_path, req.createdAt)
43 | return success_response(data=new_item, status_code=201)
44 | except ValueError as exc:
45 | return error_response(str(exc), 400)
46 | except IOError as exc:
47 | logger.error("IOError adding todo for project %s: %s", project_path, exc)
48 | return error_response(str(exc), "Failed to save todo", 500)
49 | except Exception as exc:
50 | logger.exception("Error adding todo for project: %s", project_path)
51 | return error_response(str(exc), "Failed to add todo", 500)
52 |
53 |
54 | @todo_blueprint.put("/api/todos/")
55 | def update_todo_endpoint(todo_id: int):
56 | project_path = request.args.get("projectPath")
57 | payload = request.get_json(silent=True) or {}
58 | try:
59 | req = TodoUpdateRequest(**payload)
60 | except ValidationError as exc:
61 | return error_response(f"Validation error: {exc.errors()}", 400)
62 |
63 | try:
64 | updated_item = _todo_s.update_todo(todo_id, req.completed, project_path)
65 | if updated_item is None:
66 | return error_response("Todo not found.", 404)
67 | return success_response(data=updated_item)
68 | except IOError as exc:
69 | logger.error("IOError updating todo %d for project %s: %s", todo_id, project_path, exc)
70 | return error_response(str(exc), "Failed to save todo update", 500)
71 | except Exception as exc:
72 | logger.exception("Error updating todo %d for project: %s", todo_id, project_path)
73 | return error_response(str(exc), "Failed to update todo", 500)
74 |
75 |
76 | @todo_blueprint.delete("/api/todos/")
77 | def delete_todo_endpoint(todo_id: int):
78 | project_path = request.args.get("projectPath")
79 | try:
80 | deleted = _todo_s.delete_todo(todo_id, project_path)
81 | if not deleted:
82 | return error_response("Todo not found.", 404)
83 | return "", 204
84 | except IOError as exc:
85 | logger.error("IOError deleting todo %d for project %s: %s", todo_id, project_path, exc)
86 | return error_response(str(exc), "Failed to save after deletion", 500)
87 | except Exception as exc:
88 | logger.exception("Error deleting todo %d for project: %s", todo_id, project_path)
89 | return error_response(str(exc), "Failed to delete todo", 500)
90 |
--------------------------------------------------------------------------------
/python_backend/controllers/exclusions_controller.py:
--------------------------------------------------------------------------------
1 | # python_backend/controllers/exclusions_controller.py
2 | import logging
3 | import os
4 | from flask import Blueprint, request
5 | from pydantic import ValidationError
6 |
7 | from services.exclusion_service import ExclusionService
8 | from repositories.file_storage import FileStorageRepository
9 | from utils.response_utils import success_response, error_response
10 | from models.request_models import GlobalExclusionsRequest, LocalExclusionsRequest # NEW
11 |
12 | logger = logging.getLogger(__name__)
13 | exclusions_blueprint = Blueprint("exclusions_blueprint", __name__)
14 |
15 | # ─── dependencies ───────────────────────────────────────────────────────────
16 | _storage = FileStorageRepository()
17 | _exclude = ExclusionService(storage_repo=_storage)
18 | # ────────────────────────────────────────────────────────────────────────────
19 |
20 |
21 | @exclusions_blueprint.route("/api/exclusions", methods=["GET", "POST"])
22 | def handle_global_exclusions():
23 | if request.method == "GET":
24 | try:
25 | exclusions = _exclude.get_global_exclusions()
26 | return success_response(data=exclusions)
27 | except Exception as exc:
28 | logger.exception("Error getting global exclusions")
29 | return error_response(str(exc), "Failed to retrieve global exclusions", 500)
30 |
31 | # ---------- POST ----------
32 | payload = request.get_json(silent=True) or {}
33 | try:
34 | req = GlobalExclusionsRequest(**payload)
35 | except ValidationError as exc:
36 | return error_response(f"Validation error: {exc.errors()}", 400)
37 |
38 | try:
39 | updated = _exclude.update_global_exclusions(req.exclusions)
40 | return success_response(data=updated, message="Global exclusions updated.")
41 | except ValueError as exc:
42 | return error_response(str(exc), 400)
43 | except IOError as exc:
44 | logger.error("IOError updating global exclusions: %s", exc)
45 | return error_response(str(exc), "Failed to write global exclusions file", 500)
46 | except Exception as exc:
47 | logger.exception("Error updating global exclusions")
48 | return error_response(str(exc), "Failed to update global exclusions", 500)
49 |
50 |
51 | @exclusions_blueprint.route("/api/localExclusions", methods=["GET", "POST"])
52 | def handle_local_exclusions():
53 | project_path = request.args.get("projectPath")
54 | if not project_path:
55 | return error_response("Missing 'projectPath' query parameter.", 400)
56 | if not os.path.isdir(project_path):
57 | return error_response(f"Project path '{project_path}' not found or is not a directory.", 404)
58 |
59 | if request.method == "GET":
60 | try:
61 | exclusions = _exclude.get_local_exclusions(project_path)
62 | return success_response(data=exclusions)
63 | except ValueError as exc:
64 | return error_response(str(exc), 400)
65 | except Exception as exc:
66 | logger.exception("Error getting local exclusions for %s", project_path)
67 | return error_response(str(exc), "Failed to retrieve local exclusions", 500)
68 |
69 | # ---------- POST ----------
70 | payload = request.get_json(silent=True) or {}
71 | try:
72 | req = LocalExclusionsRequest(**payload)
73 | except ValidationError as exc:
74 | return error_response(f"Validation error: {exc.errors()}", 400)
75 |
76 | try:
77 | updated = _exclude.update_local_exclusions(project_path, req.localExclusions)
78 | return success_response(data=updated, message="Local exclusions updated.")
79 | except ValueError as exc:
80 | return error_response(str(exc), 400)
81 | except IOError as exc:
82 | logger.error("IOError updating local exclusions for %s: %s", project_path, exc)
83 | return error_response(str(exc), "Failed to write local exclusions file", 500)
84 | except Exception as exc:
85 | logger.exception("Error updating local exclusions for %s", project_path)
86 | return error_response(str(exc), "Failed to update local exclusions", 500)
87 |
--------------------------------------------------------------------------------
/python_backend/services/exclusion_service.py:
--------------------------------------------------------------------------------
1 | # File: python_backend/services/exclusion_service.py
2 | # NEW FILE
3 | import os
4 | import logging
5 | from typing import List
6 | from services.service_exceptions import wrap_service_methods
7 | from repositories.file_storage import FileStorageRepository
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | @wrap_service_methods
13 | class ExclusionService:
14 | """Service layer for managing global and local exclusions."""
15 |
16 | def __init__(self, storage_repo: FileStorageRepository):
17 | self.storage_repo = storage_repo
18 | # Define paths relative to the *backend* directory structure might be better
19 | # Or pass project_root explicitly during initialization or method calls
20 | self.project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
21 | self.global_ignore_filename = 'ignoreDirs.txt'
22 | self.local_exclusions_filename = 'localExclusions.json'
23 | self.codetoprompt_dir_name = '.codetoprompt'
24 |
25 | def get_global_exclusions(self) -> List[str]:
26 | """Reads global exclusions from ignoreDirs.txt."""
27 | file_path = os.path.join(self.project_root, self.global_ignore_filename)
28 | try:
29 | return self.storage_repo.read_lines(file_path)
30 | except Exception as e:
31 | logger.error(f"Failed to read global exclusions: {e}")
32 | # Decide if returning empty list or raising is better
33 | return []
34 |
35 | def update_global_exclusions(self, exclusions: List[str]) -> List[str]:
36 | """Updates the global exclusions file."""
37 | if not isinstance(exclusions, list):
38 | raise ValueError("Exclusions must be a list of strings")
39 |
40 | file_path = os.path.join(self.project_root, self.global_ignore_filename)
41 | cleaned_exclusions = [ex.strip() for ex in exclusions if ex.strip()]
42 | try:
43 | self.storage_repo.write_lines(file_path, cleaned_exclusions)
44 | return cleaned_exclusions
45 | except IOError as e:
46 | logger.error(f"Failed to write global exclusions: {e}")
47 | raise # Re-raise IOErrors to be caught by controller
48 |
49 | def _get_local_exclusions_path(self, project_path: str) -> str:
50 | """Constructs the path to the local exclusions file for a project."""
51 | if not project_path or not os.path.isdir(project_path):
52 | raise ValueError("Invalid project path provided.")
53 | return os.path.join(project_path, self.codetoprompt_dir_name, self.local_exclusions_filename)
54 |
55 | def get_local_exclusions(self, project_path: str) -> List[str]:
56 | """Reads local exclusions for a specific project."""
57 | file_path = self._get_local_exclusions_path(project_path)
58 | try:
59 | # Default to empty list if file doesn't exist or is invalid
60 | data = self.storage_repo.read_json(file_path, default=[])
61 | if not isinstance(data, list):
62 | logger.warning(f"Local exclusions file {file_path} is not a list. Returning empty.")
63 | return []
64 | # Ensure all items are strings
65 | return [str(item) for item in data]
66 | except Exception as e:
67 | logger.error(f"Failed to read local exclusions for {project_path}: {e}")
68 | return []
69 |
70 | def update_local_exclusions(self, project_path: str, exclusions: List[str]) -> List[str]:
71 | """Updates the local exclusions for a specific project."""
72 | if not isinstance(exclusions, list):
73 | raise ValueError("Local exclusions must be a list of strings")
74 |
75 | file_path = self._get_local_exclusions_path(project_path)
76 | # Ensure all items are strings before saving
77 | cleaned_exclusions = [str(item).strip() for item in exclusions if str(item).strip()]
78 | try:
79 | self.storage_repo.write_json(file_path, cleaned_exclusions)
80 | return cleaned_exclusions
81 | except IOError as e:
82 | logger.error(f"Failed to write local exclusions for {project_path}: {e}")
83 | raise
--------------------------------------------------------------------------------
/python_backend/controllers/kanban_controller.py:
--------------------------------------------------------------------------------
1 | # python_backend/controllers/kanban_controller.py
2 | import logging
3 | from flask import Blueprint, request
4 | from pydantic import ValidationError
5 |
6 | from services.kanban_service import KanbanService
7 | from repositories.file_storage import FileStorageRepository
8 | from utils.response_utils import success_response, error_response
9 | from models.request_models import KanbanCreateRequest, KanbanUpdateRequest # NEW
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 | kanban_bp = Blueprint("kanban_bp", __name__)
14 | storage_repo = FileStorageRepository()
15 | kanban_service = KanbanService(storage_repo)
16 |
17 | # ───────────────────────────── collection ──────────────────────────
18 | @kanban_bp.route("/api/kanban", methods=["GET", "POST"])
19 | def collection():
20 | project = request.args.get("projectPath")
21 |
22 | if request.method == "GET":
23 | try:
24 | items = kanban_service.list_items(project)
25 | return success_response(data=items)
26 | except Exception as exc:
27 | logger.exception("Error listing kanban items for project: %s", project)
28 | return error_response(str(exc), "Failed to list kanban items", 500)
29 |
30 | # ---------- POST (create) ----------
31 | payload = request.get_json(silent=True) or {}
32 | try:
33 | req = KanbanCreateRequest(**payload)
34 | except ValidationError as exc:
35 | logger.warning("Validation error creating kanban item: %s", exc)
36 | return error_response(f"Validation error: {exc.errors()}", 400)
37 |
38 | try:
39 | new_item = kanban_service.add_item(req.dict(exclude_unset=True), project)
40 | return success_response(data=new_item, status_code=201)
41 | except ValueError as exc:
42 | return error_response(str(exc), 400)
43 | except IOError as exc:
44 | logger.error("IOError creating kanban item for project %s: %s", project, exc)
45 | return error_response(str(exc), "Failed to save kanban item (file system error)", 500)
46 | except Exception as exc:
47 | logger.exception("Error creating kanban item for project: %s", project)
48 | return error_response(str(exc), "Failed to create kanban item", 500)
49 |
50 | # ───────────────────────────── single item ─────────────────────────
51 | @kanban_bp.route("/api/kanban/", methods=["PUT", "DELETE"])
52 | def item(item_id: int):
53 | project = request.args.get("projectPath")
54 |
55 | if request.method == "PUT":
56 | patch = request.get_json(silent=True) or {}
57 | try:
58 | req = KanbanUpdateRequest(**patch)
59 | except ValidationError as exc:
60 | return error_response(f"Validation error: {exc.errors()}", 400)
61 |
62 | try:
63 | updated = kanban_service.update_item(item_id, req.dict(exclude_unset=True), project)
64 | if updated is None:
65 | return error_response("Item not found", 404)
66 | return success_response(data=updated)
67 | except ValueError as exc:
68 | return error_response(str(exc), 400)
69 | except IOError as exc:
70 | logger.error("IOError updating kanban item %d for project %s: %s", item_id, project, exc)
71 | return error_response(str(exc), "Failed to save kanban item update (file system error)", 500)
72 | except Exception as exc:
73 | logger.exception("Error updating kanban item %d for project: %s", item_id, project)
74 | return error_response(str(exc), "Failed to update kanban item", 500)
75 |
76 | # ---------- DELETE ----------
77 | try:
78 | deleted = kanban_service.delete_item(item_id, project)
79 | if not deleted:
80 | return error_response("Item not found", 404)
81 | return "", 204
82 | except IOError as exc:
83 | logger.error("IOError deleting kanban item %d for project %s: %s", item_id, project, exc)
84 | return error_response(str(exc), "Failed to save after deletion (file system error)", 500)
85 | except Exception as exc:
86 | logger.exception("Error deleting kanban item %d for project: %s", item_id, project)
87 | return error_response(str(exc), "Failed to delete kanban item", 500)
88 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/services/exclusionServiceHooks.ts:
--------------------------------------------------------------------------------
1 | // File: services/exclusionServiceHooks.ts
2 | // NEW FILE
3 | import { useCallback } from 'react';
4 | import { useExclusionStore } from '@/stores/useExclusionStore';
5 | import { useProjectStore } from '@/stores/useProjectStore'; // To trigger tree reload
6 | import { useAppStore } from '@/stores/useAppStore';
7 | import { fetchApi } from './apiService';
8 | import { useProjectService } from './projectServiceHooks'; // To get reload function
9 |
10 | export function useExclusionService() {
11 | const {
12 | setGlobalExclusions, setIsLoadingGlobal, setIsSavingGlobal,
13 | setLocalExclusions, setIsLoadingLocal, setIsSavingLocal
14 | } = useExclusionStore();
15 | const { projectPath } = useProjectStore(); // Need project path for local exclusions
16 | const { loadProjectTree } = useProjectService(); // Need to reload tree after global changes
17 | const { setError } = useAppStore();
18 |
19 | // === Global Exclusions ===
20 | const fetchGlobalExclusions = useCallback(async () => {
21 | setIsLoadingGlobal(true);
22 | setError(null);
23 | const result = await fetchApi(`/api/exclusions`);
24 | if (result) {
25 | setGlobalExclusions(result);
26 | } else {
27 | setGlobalExclusions([]);
28 | }
29 | setIsLoadingGlobal(false);
30 | }, [setIsLoadingGlobal, setGlobalExclusions, setError]);
31 |
32 | const updateGlobalExclusions = useCallback(async (exclusions: string[]) => {
33 | setIsSavingGlobal(true);
34 | setError(null);
35 | const result = await fetchApi(`/api/exclusions`, {
36 | method: 'POST',
37 | body: JSON.stringify({ exclusions }),
38 | });
39 | if (result) {
40 | setGlobalExclusions(result);
41 | // Refresh project tree as global exclusions changed
42 | if (projectPath) {
43 | await loadProjectTree();
44 | }
45 | }
46 | // Error handled by fetchApi
47 | setIsSavingGlobal(false);
48 | // Return success status if needed by the component
49 | return result !== null;
50 | }, [setIsSavingGlobal, setGlobalExclusions, setError, projectPath, loadProjectTree]);
51 |
52 | // === Local Exclusions ===
53 | const fetchLocalExclusions = useCallback(async () => {
54 | // Get latest project path from store
55 | const currentProjectPath = useProjectStore.getState().projectPath;
56 | if (!currentProjectPath) {
57 | setLocalExclusions([]); // Clear if no project path
58 | return;
59 | }
60 |
61 | setIsLoadingLocal(true);
62 | setError(null);
63 | const result = await fetchApi(
64 | `/api/localExclusions?projectPath=${encodeURIComponent(currentProjectPath)}`
65 | );
66 | if (result) {
67 | setLocalExclusions(result);
68 | } else {
69 | setLocalExclusions([]);
70 | }
71 | setIsLoadingLocal(false);
72 | }, [setIsLoadingLocal, setLocalExclusions, setError]); // projectPath is implicit via getState
73 |
74 | const updateLocalExclusions = useCallback(async (exclusions: string[]) => {
75 | // Get latest project path from store
76 | const currentProjectPath = useProjectStore.getState().projectPath;
77 | if (!currentProjectPath) {
78 | setError("Cannot update local exclusions without a project path.");
79 | return false; // Indicate failure
80 | }
81 |
82 | setIsSavingLocal(true); // Use a specific saving state if needed
83 | setError(null);
84 | const result = await fetchApi(
85 | `/api/localExclusions?projectPath=${encodeURIComponent(currentProjectPath)}`, {
86 | method: 'POST',
87 | body: JSON.stringify({ localExclusions: exclusions }),
88 | }
89 | );
90 | if (result) {
91 | setLocalExclusions(result);
92 | }
93 | // Error handled by fetchApi
94 | setIsSavingLocal(false);
95 | return result !== null;
96 | }, [setIsSavingLocal, setLocalExclusions, setError]); // projectPath implicit
97 |
98 | return {
99 | fetchGlobalExclusions,
100 | updateGlobalExclusions,
101 | fetchLocalExclusions,
102 | updateLocalExclusions
103 | };
104 | }
105 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-offline-llm-tool",
3 | "version": "1.2.2",
4 | "description": "Generate finely-tuned LLM prompts from your code base.",
5 | "author": {
6 | "name": "Aytug Berk Sezer",
7 | "email": "aytugsezer@gmail.com"
8 | },
9 | "private": true,
10 | "main": "electron/main.js",
11 | "scripts": {
12 | "dev": "kill-port 3010 && next dev -p 3010 -H 127.0.0.1",
13 | "build": "npx --yes node@20 ./node_modules/next/dist/bin/next build",
14 | "export": "npm run build",
15 | "start": "echo \"For Electron app, use 'npm run electron:prod:local' or run the packaged application. 'npm start' (next start) is not used with 'output: export'. To serve a static export, use 'npm run electron:prod:local'.\"",
16 | "lint": "next lint",
17 | "backend": "python_backend/venv/bin/python python_backend/app.py",
18 | "backend:prod": "python_backend/venv/bin/python -m gunicorn -w 2 -b 127.0.0.1:5010 --chdir python_backend app:app",
19 | "postinstall": "node scripts/postinstall.js",
20 | "electron:dev": "concurrently --kill-others \"npm run dev\" \"npm run backend\" \"wait-on tcp:5010 tcp:3010 && node scripts/run-electron.js\"",
21 | "electron:prod:local": "npm run build && cross-env APP_ENV=production concurrently --kill-others \"python_backend/venv/bin/python -m gunicorn -w 2 -b 127.0.0.1:5010 --chdir python_backend app:app\" \"node scripts/run-electron.js\"",
22 | "electron:pack": "npm run build && electron-builder --dir",
23 | "electron:build:linux": "npm run build && electron-builder --linux deb AppImage",
24 | "test": "node scripts/autotest.js"
25 | },
26 | "engines": {
27 | "node": ">=20"
28 | },
29 | "engine-strict": true,
30 | "dependencies": {
31 | "@dnd-kit/core": "^6.3.1",
32 | "@dnd-kit/modifiers": "^9.0.0",
33 | "@dnd-kit/sortable": "^10.0.0",
34 | "@dnd-kit/utilities": "^3.2.2",
35 | "@hello-pangea/dnd": "^18.0.1",
36 | "@radix-ui/react-checkbox": "^1.1.4",
37 | "@radix-ui/react-dialog": "^1.1.6",
38 | "@radix-ui/react-label": "^2.1.2",
39 | "@radix-ui/react-progress": "^1.1.2",
40 | "@radix-ui/react-scroll-area": "^1.2.3",
41 | "@radix-ui/react-select": "^2.1.6",
42 | "@radix-ui/react-separator": "^1.1.2",
43 | "@radix-ui/react-slot": "^1.1.2",
44 | "@radix-ui/react-tabs": "^1.1.3",
45 | "@radix-ui/react-toast": "^1.1.4",
46 | "@radix-ui/react-tooltip": "^1.2.6",
47 | "@tailwindcss/typography": "^0.5.16",
48 | "class-variance-authority": "^0.7.1",
49 | "clsx": "^2.1.1",
50 | "fuse.js": "^7.1.0",
51 | "immer": "^10.0.4",
52 | "lucide-react": "^0.477.0",
53 | "next": "^15.3.1",
54 | "node-fetch": "^3.3.2",
55 | "picomatch": "^4.0.2",
56 | "react": "18.2.0",
57 | "react-dom": "18.2.0",
58 | "react-error-boundary": "^4.0.11",
59 | "react-virtualized-auto-sizer": "^1.0.7",
60 | "react-window": "^1.8.11",
61 | "swr": "^2.3.0",
62 | "tailwind-merge": "^3.0.2",
63 | "tiktoken": "^1.0.3",
64 | "typescript": "5.2.2",
65 | "zod": "^3.24.4",
66 | "zustand": "^5.0.3"
67 | },
68 | "devDependencies": {
69 | "@types/node": "18.17.12",
70 | "@types/react": "^19.1.5",
71 | "@types/react-dom": "18.2.7",
72 | "@typescript-eslint/eslint-plugin": "^6.7.5",
73 | "@typescript-eslint/parser": "^6.7.5",
74 | "autoprefixer": "^10.4.20",
75 | "concurrently": "^8.2.2",
76 | "cross-env": "^7.0.3",
77 | "electron": "^29.3.3",
78 | "electron-builder": "^24.13.1",
79 | "electron-reload": "^1.5.0",
80 | "eslint": "^8.57.0",
81 | "eslint-config-next": "13.4.8",
82 | "jest-fetch-mock": "^3.0.3",
83 | "kill-port": "^2.0.1",
84 | "postcss": "^8.4.49",
85 | "tailwind-scrollbar": "^3.0.5",
86 | "tailwindcss": "^3.4.16",
87 | "wait-on": "^7.0.1",
88 | "xvfb": "^0.2.1"
89 | },
90 | "build": {
91 | "productName": "CodeToPromptGenerator",
92 | "asar": true,
93 | "files": [
94 | "electron/**/*",
95 | "out/**/*",
96 | "python_backend/**/*",
97 | "package.json"
98 | ],
99 | "extraResources": [
100 | {
101 | "from": "python_backend",
102 | "to": "python_backend"
103 | }
104 | ],
105 | "linux": {
106 | "target": [
107 | "deb",
108 | "AppImage"
109 | ],
110 | "category": "Development",
111 | "maintainer": "Aytug Berk Sezer",
112 | "synopsis": "Generate finely-tuned LLM prompts from your code base"
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/lib/hooks/useProjectLogic.ts:
--------------------------------------------------------------------------------
1 | // lib/hooks/useProjectLogic.ts
2 | import { useEffect, useMemo, useCallback } from "react";
3 | import { useProjectStore } from "@/stores/useProjectStore";
4 | import { useExclusionStore } from "@/stores/useExclusionStore";
5 | import { useProjectService } from "@/services/projectServiceHooks";
6 | import { applyExtensionFilter, applySearchFilter, flattenTree } from "@/lib/fileFilters";
7 |
8 | /**
9 | * Hook for managing project-related logic including:
10 | * - Project path changes
11 | * - File tree loading and filtering
12 | * - File selection operations
13 | */
14 | export function useProjectLogic() {
15 | // Store selectors
16 | const projectPath = useProjectStore((s) => s.projectPath);
17 | const setProjectPath = useProjectStore((s) => s.setProjectPath);
18 | const fileTree = useProjectStore((s) => s.fileTree);
19 | const selectedFilePaths = useProjectStore((s) => s.selectedFilePaths);
20 | const setSelectedFilePaths = useProjectStore((s) => s.setSelectedFilePaths);
21 | const isLoadingTree = useProjectStore((s) => s.isLoadingTree);
22 | const filesData = useProjectStore((s) => s.filesData);
23 | const fileSearchTerm = useProjectStore((s) => s.fileSearchTerm);
24 | const setFileSearchTerm = useProjectStore((s) => s.setFileSearchTerm);
25 | const selectAllFiles = useProjectStore((s) => s.selectAllFiles);
26 | const deselectAllFiles = useProjectStore((s) => s.deselectAllFiles);
27 |
28 | const globalExclusions = useExclusionStore((s) => s.globalExclusions);
29 | const localExclusions = useExclusionStore((s) => s.localExclusions);
30 | const extensionFilters = useExclusionStore((s) => s.extensionFilters);
31 |
32 | // Services
33 | const { loadProjectTree, loadSelectedFileContents } = useProjectService();
34 |
35 | // Effects for project lifecycle
36 | useEffect(() => {
37 | if (projectPath) {
38 | loadProjectTree();
39 | } else {
40 | useProjectStore.setState({
41 | fileTree: [],
42 | selectedFilePaths: [],
43 | filesData: [],
44 | });
45 | }
46 | }, [projectPath, loadProjectTree]);
47 |
48 | useEffect(() => {
49 | if (projectPath && selectedFilePaths.length) {
50 | loadSelectedFileContents();
51 | } else {
52 | useProjectStore.setState({ filesData: [] });
53 | }
54 | }, [selectedFilePaths, projectPath, loadSelectedFileContents]);
55 |
56 | // Derived data
57 | const filteredTree = useMemo(() => {
58 | const extFiltered = extensionFilters.length
59 | ? applyExtensionFilter(fileTree, extensionFilters)
60 | : fileTree;
61 | return fileSearchTerm.trim()
62 | ? applySearchFilter(extFiltered, fileSearchTerm.toLowerCase())
63 | : extFiltered;
64 | }, [fileTree, extensionFilters, fileSearchTerm]);
65 |
66 | const localExclusionsSet = useMemo(
67 | () => new Set(localExclusions),
68 | [localExclusions],
69 | );
70 |
71 | const selectedFileCount = useMemo(
72 | () => selectedFilePaths.filter((p) => !p.endsWith("/")).length,
73 | [selectedFilePaths],
74 | );
75 |
76 | const totalTokens = useMemo(
77 | () => filesData.reduce((a, f) => a + (f.tokenCount ?? 0), 0),
78 | [filesData],
79 | );
80 |
81 | // Event handlers
82 | const handleSelectAll = useCallback(() => {
83 | if (!projectPath) return;
84 | const allVisibleFiles = flattenTree(filteredTree).filter(
85 | (p) => !p.endsWith("/"),
86 | );
87 | selectAllFiles(
88 | allVisibleFiles,
89 | new Set(globalExclusions),
90 | localExclusionsSet,
91 | );
92 | }, [projectPath, filteredTree, selectAllFiles, globalExclusions, localExclusionsSet]);
93 |
94 | const handleRefresh = useCallback(async () => {
95 | if (!projectPath) return;
96 | await loadProjectTree();
97 | if (useProjectStore.getState().selectedFilePaths.length > 0) {
98 | await loadSelectedFileContents();
99 | }
100 | }, [projectPath, loadProjectTree, loadSelectedFileContents]);
101 |
102 | const handlePathSelected = useCallback((path: string) => {
103 | setProjectPath(path);
104 | }, [setProjectPath]);
105 |
106 | return {
107 | // State
108 | projectPath,
109 | isLoadingTree,
110 | filteredTree,
111 | selectedFilePaths,
112 | fileSearchTerm,
113 | selectedFileCount,
114 | totalTokens,
115 | fileTree,
116 |
117 | // Actions
118 | handlePathSelected,
119 | handleRefresh,
120 | handleSelectAll,
121 | deselectAllFiles,
122 | setSelectedFilePaths,
123 | setFileSearchTerm,
124 | };
125 | }
--------------------------------------------------------------------------------
/services/kanbanServiceHooks.ts:
--------------------------------------------------------------------------------
1 | // FILE: services/kanbanServiceHooks.ts
2 | import { useCallback } from "react";
3 | import { z } from "zod";
4 | import { fetchApi } from "@/services/apiService";
5 | import { useKanbanStore } from "@/stores/useKanbanStore";
6 | import { useProjectStore } from "@/stores/useProjectStore";
7 | import { useAppStore } from "@/stores/useAppStore";
8 | import { KanbanItem, KanbanStatus, KanbanItemSchema } from '@/types'; // Import KanbanItemSchema
9 |
10 |
11 | /* ------------------- zod schemas ------------------- */
12 | // Use KanbanItemSchema directly from types/index.ts
13 | // const ItemSchema = z.object({ ... }); // This local definition is no longer needed
14 |
15 | const ArrSchema = z.array(KanbanItemSchema); // Use the imported schema
16 |
17 | function safeParse(schema: T, data: unknown): z.infer | null {
18 | if (data === null || data === undefined) { // Added undefined check
19 | return null;
20 | }
21 |
22 | const r = schema.safeParse(data);
23 | if (!r.success) {
24 | console.error("Schema validation failed", r.error.flatten()); // Log flattened errors for better readability
25 | useAppStore.getState().setError("Received malformed data from server. Check console for details.");
26 | return null;
27 | }
28 | return r.data;
29 | }
30 |
31 | export function useKanbanService() {
32 | const { projectPath } = useProjectStore();
33 | const {
34 | setAll,
35 | setLoading,
36 | setSaving,
37 | moveItem: moveItemInStore,
38 | } = useKanbanStore();
39 |
40 | const { setError } = useAppStore();
41 |
42 | const baseQueryParam = projectPath ? `projectPath=${encodeURIComponent(projectPath)}` : '';
43 | const baseEndpoint = "/api/kanban";
44 |
45 | /* ---------------- list ---------------- */
46 | const load = useCallback(async () => {
47 | if (!projectPath) {
48 | setAll([]);
49 | return;
50 | }
51 | setLoading(true);
52 | const raw = await fetchApi(`${baseEndpoint}?${baseQueryParam}`);
53 | const data = safeParse(ArrSchema, raw); // ArrSchema now uses the correct KanbanItemSchema
54 | setAll(data ?? []);
55 | setLoading(false);
56 | }, [projectPath, setAll, setLoading, baseQueryParam]);
57 |
58 | /* ---------------- create -------------- */
59 | const create = useCallback(async (draft: Omit) => {
60 | if (!projectPath) return null;
61 | setSaving(true);
62 | setError(null);
63 |
64 | const raw = await fetchApi(`${baseEndpoint}?${baseQueryParam}`, {
65 | method: "POST",
66 | body: JSON.stringify(draft),
67 | });
68 |
69 | const item = safeParse(KanbanItemSchema, raw); // Use KanbanItemSchema directly
70 |
71 | if (item) {
72 | await load();
73 | }
74 | setSaving(false);
75 | return item;
76 | }, [projectPath, load, setSaving, setError, baseQueryParam]);
77 |
78 | /* ---------------- update/patch -------------- */
79 | const patch = useCallback(async (itemData: Partial & Pick) => {
80 | if (!projectPath) return null;
81 | setSaving(true);
82 | setError(null);
83 |
84 | const url = `${baseEndpoint}/${itemData.id}?${baseQueryParam}`;
85 |
86 | const raw = await fetchApi(url, {
87 | method: "PUT",
88 | body: JSON.stringify(itemData),
89 | });
90 |
91 | const updatedItem = safeParse(KanbanItemSchema, raw); // Use KanbanItemSchema directly
92 |
93 | if (updatedItem) {
94 | await load();
95 | } else {
96 | console.error("Failed to update item via patch.");
97 | // Error should be set by safeParse or fetchApi, load() will refresh to consistent state
98 | await load();
99 | }
100 | setSaving(false);
101 | return updatedItem;
102 | }, [projectPath, load, setSaving, setError, baseQueryParam]);
103 |
104 | /* ---------------- delete -------------- */
105 | const deleteItem = useCallback(async (itemId: number) => {
106 | if (!projectPath) return false;
107 | setSaving(true);
108 | setError(null);
109 |
110 | const url = `${baseEndpoint}/${itemId}?${baseQueryParam}`;
111 | await fetchApi(url, { method: 'DELETE' });
112 |
113 | setSaving(false);
114 | const errorState = useAppStore.getState().error;
115 | if (errorState) {
116 | return false;
117 | }
118 |
119 | await load();
120 | return true;
121 | }, [projectPath, load, setSaving, setError, baseQueryParam]);
122 |
123 | /* ---------------- move (dnd) ---------- */
124 | const relocate = useCallback((itemId: number, newStatus: KanbanStatus, newIndex?: number) => {
125 | moveItemInStore(itemId, newStatus, newIndex);
126 | }, [moveItemInStore]);
127 |
128 |
129 | return { load, create, patch, deleteItem, relocate };
130 | }
--------------------------------------------------------------------------------
/python_backend/services/service_exceptions.py:
--------------------------------------------------------------------------------
1 | """
2 | Shared service-layer exception taxonomy and helpers
3 | ===================================================
4 |
5 | ▶ Add this file to *python_backend/services/*.
6 | ▶ Decorate every service class with **@wrap_service_methods**.
7 |
8 | Any uncaught native exception raised inside a *public* (non-underscore)
9 | service method is:
10 |
11 | • mapped to the correct custom exception,
12 | • **logged** (`logger.exception` for tracebacks, `logger.error` otherwise),
13 | • re-raised so the calling controller can translate it to an HTTP status.
14 |
15 | This keeps individual services clean – they only need to `raise` when they
16 | *know* the precise error class; everything else is normalised for them.
17 | """
18 | from __future__ import annotations
19 |
20 | import functools
21 | import logging
22 | from typing import Callable, TypeVar
23 |
24 | try: # httpx is optional – only used for mapping
25 | import httpx # noqa: WPS433 (third-party import in lib)
26 | except ModuleNotFoundError: # pragma: no cover
27 | httpx = None # type: ignore
28 |
29 | __all__ = [
30 | # base
31 | "ServiceError",
32 | # concrete leaf classes
33 | "InvalidInputError",
34 | "ResourceNotFoundError",
35 | "PermissionDeniedError",
36 | "ConfigurationError",
37 | "UpstreamServiceError",
38 | # helpers
39 | "wrap_service_methods",
40 | ]
41 |
42 | log = logging.getLogger(__name__)
43 |
44 | # ────────────────────────────────
45 | # 1 · Exception hierarchy
46 | # ────────────────────────────────
47 | class ServiceError(Exception):
48 | """Base-class of *all* service-layer errors."""
49 |
50 |
51 | class InvalidInputError(ServiceError):
52 | """Bad caller arguments that slipped through Pydantic validation."""
53 |
54 |
55 | class ResourceNotFoundError(ServiceError):
56 | """Local resource (file / dir / record) is missing."""
57 |
58 |
59 | class PermissionDeniedError(ServiceError):
60 | """The OS blocked access (read/write/delete)."""
61 |
62 |
63 | class ConfigurationError(ServiceError):
64 | """Server mis-configuration – e.g. missing environment variables."""
65 |
66 |
67 | class UpstreamServiceError(ServiceError):
68 | """3rd-party / network / HTTP failures."""
69 |
70 |
71 | # ────────────────────────────────
72 | # 2 · Automatic translation glue
73 | # ────────────────────────────────
74 | _F = TypeVar("_F", bound=Callable[..., object])
75 |
76 |
77 | def _translate(exc: Exception) -> ServiceError:
78 | """Map any *native* exception onto our taxonomy."""
79 | if isinstance(exc, ServiceError): # already normalised
80 | return exc
81 |
82 | # Filesystem
83 | if isinstance(exc, FileNotFoundError):
84 | return ResourceNotFoundError(str(exc))
85 | if isinstance(exc, PermissionError):
86 | return PermissionDeniedError(str(exc))
87 |
88 | # Configuration
89 | if isinstance(exc, KeyError):
90 | return ConfigurationError(str(exc))
91 |
92 | # Validation
93 | if isinstance(exc, ValueError):
94 | return InvalidInputError(str(exc))
95 |
96 | # Network / HTTP (optional)
97 | if httpx and isinstance(exc, httpx.HTTPError):
98 | return UpstreamServiceError(str(exc))
99 |
100 | # Fallback – treat as upstream/internal
101 | return UpstreamServiceError(str(exc))
102 |
103 |
104 | def _wrap_one(func: _F) -> _F: # type: ignore[misc]
105 | """Decorator used internally by :pyfunc:`wrap_service_methods`."""
106 | logger = logging.getLogger(func.__module__)
107 |
108 | @functools.wraps(func)
109 | def wrapper(*args, **kwargs): # type: ignore[override]
110 | try:
111 | return func(*args, **kwargs)
112 | except Exception as exc: # noqa: BLE001
113 | mapped = _translate(exc)
114 | if isinstance(mapped, UpstreamServiceError):
115 | logger.error("%s → %s", func.__qualname__, mapped)
116 | else:
117 | logger.exception("%s failed: %s", func.__qualname__, mapped)
118 | raise mapped from exc
119 |
120 | return wrapper # type: ignore[return-value]
121 |
122 |
123 | def wrap_service_methods(cls):
124 | """
125 | Class decorator – drop-in upgrade for *any* service.
126 |
127 | It wraps **all public methods** (attributes that are callable and **do not**
128 | start with an underscore) with `_wrap_one`, guaranteeing that controllers
129 | only ever see our custom error classes.
130 |
131 | Example
132 | -------
133 | from services.service_exceptions import wrap_service_methods
134 |
135 | @wrap_service_methods
136 | class TodoService:
137 | ...
138 | """
139 | for name, attr in cls.__dict__.items():
140 | if callable(attr) and not name.startswith("_"):
141 | setattr(cls, name, _wrap_one(attr))
142 | return cls
143 |
--------------------------------------------------------------------------------
/views/SettingsModalView.tsx:
--------------------------------------------------------------------------------
1 | // views/SettingsModalView.tsx
2 | import React from "react";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | DialogFooter,
9 | } from "@/components/ui/dialog";
10 | import { Input } from "@/components/ui/input";
11 | import { Button } from "@/components/ui/button";
12 | import { Label } from "@/components/ui/label";
13 | import { useAppStore } from "@/stores/useAppStore";
14 | import { KeyRound, PlusCircle } from "lucide-react";
15 |
16 | interface SettingsModalViewProps {
17 | apiKeyDraft: string;
18 | setApiKeyDraft: (key: string) => void;
19 | saveApiKey: () => void; // This function from useHomePageLogic will handle saving and then closing the modal.
20 | }
21 |
22 | const SettingsModalView: React.FC = ({
23 | apiKeyDraft,
24 | setApiKeyDraft,
25 | saveApiKey,
26 | }) => {
27 | const isSettingsModalOpen = useAppStore((s) => s.isSettingsModalOpen);
28 | const closeSettingsModal = useAppStore((s) => s.closeSettingsModal);
29 |
30 | return (
31 |
84 | );
85 | };
86 |
87 | export default SettingsModalView;
--------------------------------------------------------------------------------
/services/promptServiceHooks.ts:
--------------------------------------------------------------------------------
1 | // FILE: services/promptServiceHooks.ts
2 | // FULL FILE - Updated useRefinePrompt function
3 | import { useCallback, useState } from 'react';
4 | import { usePromptStore } from '@/stores/usePromptStore';
5 | import { useAppStore } from '@/stores/useAppStore';
6 | import { fetchApi } from './apiService';
7 |
8 | // Type for the refine API response
9 | interface RefinePromptResponse {
10 | refinedPrompt: string;
11 | }
12 |
13 | export function usePromptService() {
14 | const {
15 | setMetaPromptFiles, setIsLoadingMetaList,
16 | selectedMetaFile, setMetaPrompt, setIsLoadingMetaContent,
17 | newMetaFileName, metaPrompt, setIsSavingMeta, setNewMetaFileName
18 | } = usePromptStore();
19 | const { setError } = useAppStore();
20 |
21 | const fetchMetaPromptList = useCallback(async () => {
22 | setIsLoadingMetaList(true);
23 | setError(null);
24 | const result = await fetchApi(`/api/metaprompts?action=list`);
25 | if (result) {
26 | setMetaPromptFiles(result);
27 | } else {
28 | setMetaPromptFiles([]);
29 | }
30 | setIsLoadingMetaList(false);
31 | }, [setIsLoadingMetaList, setMetaPromptFiles, setError]);
32 |
33 | const loadMetaPrompt = useCallback(async () => {
34 | const currentSelectedFile = usePromptStore.getState().selectedMetaFile;
35 | if (!currentSelectedFile) return;
36 |
37 | setIsLoadingMetaContent(true);
38 | setError(null);
39 | const result = await fetchApi<{ content: string }>(
40 | `/api/metaprompts?action=load&file=${encodeURIComponent(currentSelectedFile)}`
41 | );
42 | if (result) {
43 | setMetaPrompt(result.content ?? '');
44 | } else {
45 | setMetaPrompt('');
46 | }
47 | setIsLoadingMetaContent(false);
48 | }, [setMetaPrompt, setIsLoadingMetaContent, setError]);
49 |
50 | const saveMetaPrompt = useCallback(async () => {
51 | const currentMetaPrompt = usePromptStore.getState().metaPrompt;
52 | const currentSelectedFile = usePromptStore.getState().selectedMetaFile;
53 | const currentNewFileName = usePromptStore.getState().newMetaFileName;
54 |
55 | if (!currentMetaPrompt.trim()) {
56 | setError("Meta prompt content cannot be empty.");
57 | return;
58 | }
59 | const fileName = currentNewFileName.trim() || currentSelectedFile || `meta_${Date.now()}.txt`;
60 |
61 | setIsSavingMeta(true);
62 | setError(null);
63 | const result = await fetchApi<{ message: string }>(`/api/metaprompts`, {
64 | method: 'POST',
65 | body: JSON.stringify({ filename: fileName, content: currentMetaPrompt }),
66 | });
67 |
68 | if (result) {
69 | console.log(result.message || `Meta prompt saved as ${fileName}`);
70 | setNewMetaFileName('');
71 | await fetchMetaPromptList();
72 | }
73 | setIsSavingMeta(false);
74 | }, [setError, setIsSavingMeta, setNewMetaFileName, fetchMetaPromptList]);
75 |
76 | // --- UPDATED FUNCTION for Prompt Refinement ---
77 | const useRefinePrompt = () => {
78 | const [isRefining, setIsRefining] = useState(false);
79 | const { setError } = useAppStore();
80 |
81 | // Updated signature to accept optional treeText
82 | const refinePrompt = useCallback(async (textToRefine: string, treeText?: string): Promise => {
83 | if (!textToRefine.trim()) {
84 | setError("Cannot refine empty text.");
85 | return null;
86 | }
87 |
88 | setIsRefining(true);
89 | setError(null);
90 |
91 | // Include treeText in the request body if provided
92 | const body = { text: textToRefine, treeText: treeText };
93 |
94 | const result = await fetchApi(`/api/prompt/refine`, {
95 | method: 'POST',
96 | body: JSON.stringify(body), // Send updated body
97 | });
98 |
99 | setIsRefining(false);
100 |
101 | if (result) {
102 | if (typeof result === 'object' && result !== null && 'refinedPrompt' in result) {
103 | return result.refinedPrompt;
104 | } else {
105 | console.error("Unexpected response structure from /api/prompt/refine:", result);
106 | setError("Received unexpected data format from refinement service.");
107 | return null;
108 | }
109 | } else {
110 | return null;
111 | }
112 | }, [setError]);
113 |
114 | return { refinePrompt, isRefining };
115 | };
116 | // --- END UPDATED FUNCTION ---
117 |
118 |
119 | return { fetchMetaPromptList, loadMetaPrompt, saveMetaPrompt, useRefinePrompt };
120 | }
--------------------------------------------------------------------------------
/types/index.ts:
--------------------------------------------------------------------------------
1 | // types/index.ts
2 |
3 | // Shared / re‑exported project types – EXTENDED with Codemap + Auto‑select models.
4 | import { z } from 'zod';
5 |
6 | export interface FileNode {
7 | name: string;
8 | relativePath: string;
9 | absolutePath: string;
10 | type: "file" | "directory";
11 | children?: FileNode[];
12 | }
13 |
14 | /* — project file‑content payload — */
15 | export interface FileData {
16 | path: string;
17 | content: string;
18 | tokenCount: number;
19 | }
20 |
21 | /* — todo items — */
22 | export type TodoFilter = 'all' | 'active' | 'completed';
23 | export interface TodoItem {
24 | id: number;
25 | text: string;
26 | completed: boolean;
27 | createdAt?: string; // Keep optional for compatibility
28 | }
29 |
30 | /* ═══════════════ Codemap models ═══════════════ */
31 | export interface CodemapRequest {
32 | baseDir: string;
33 | paths: string[]; // *relative* paths
34 | }
35 |
36 | export interface CodemapInfo {
37 | classes: string[];
38 | functions: string[];
39 | references: string[];
40 | imports: Array<{
41 | module: string;
42 | symbols: string;
43 | type: string;
44 | raw: string;
45 | }>;
46 | exports: Array<{
47 | symbols: string;
48 | type: string;
49 | raw: string;
50 | }>;
51 | /** Populated when the backend failed for this file */
52 | error?: string;
53 | /** true ⇢ binary file, extraction skipped */
54 | binary?: boolean;
55 | }
56 |
57 | export type CodemapResponse = Record;
58 |
59 | /* ═══════════════ Auto‑select models ═══════════════ */
60 | export interface AutoSelectRequest {
61 | instructions: string;
62 | treePaths: string[]; // flattened *relative* paths
63 | baseDir?: string; // optional absolute project root
64 | }
65 |
66 | export interface AutoSelectResponse {
67 | selected: string[]; // list of *relative* paths
68 | llmRaw?: string; // raw model output for debugging
69 | codemap?: CodemapResponse; // optional debug summaries (when ?debug=1)
70 | }
71 |
72 | /* █████ KANBAN ██████████████████████████████████████████████████████ */
73 | export const KanbanStatusValues = ['todo', 'in-progress', 'done'] as const;
74 | export const KanbanPriorityValues = ['low', 'medium', 'high'] as const;
75 |
76 | export type KanbanStatus = typeof KanbanStatusValues[number];
77 | export type KanbanPriority = typeof KanbanPriorityValues[number];
78 |
79 | export interface KanbanItem {
80 | id: number;
81 | title: string;
82 | details?: string | null; // CHANGED from description, made nullable
83 | status: KanbanStatus;
84 | priority: KanbanPriority;
85 | dueDate?: string | null; // CHANGED from deadline, made nullable (ISO Date string)
86 | createdAt: string; // ISO Date string
87 | }
88 |
89 | /* ---------- Runtime schema (shared FE/BE) ------------------------- */
90 | export const KanbanItemSchema = z.object({
91 | id: z.number().int().nonnegative(),
92 | title: z.string().min(1).max(120),
93 | details: z.string().optional().nullable(), // CHANGED from description
94 | status: z.enum(KanbanStatusValues),
95 | priority: z.enum(KanbanPriorityValues),
96 | // Ensure dueDate and createdAt handle full ISO strings with timezone offset (e.g., "Z" for UTC)
97 | dueDate: z.string().datetime({ offset: true }).optional().nullable(), // CHANGED from deadline
98 | createdAt: z.string().datetime({ offset: true }),
99 | });
100 |
101 |
102 | // ADDED: Task interface extending KanbanItem to include userStoryIds
103 | export interface Task extends KanbanItem {
104 | userStoryIds?: number[]; // IDs of associated user stories
105 | }
106 |
107 | // ADDED: TaskSchema extending KanbanItemSchema
108 | export const TaskSchema = KanbanItemSchema.extend({
109 | userStoryIds: z.array(z.number().int()).optional(),
110 | });
111 |
112 |
113 | /* █████ USER STORY ██████████████████████████████████████████████████████ */
114 | // Using KanbanStatusValues and KanbanPriorityValues for consistency if suitable
115 | export interface UserStory {
116 | id: number;
117 | title: string;
118 | description?: string | null;
119 | acceptanceCriteria?: string | null;
120 | priority: KanbanPriority; // Reusing KanbanPriority
121 | points?: number | null;
122 | status: KanbanStatus; // Reusing KanbanStatus
123 | createdAt: string; // ISO Date string
124 | taskIds?: number[]; // IDs of associated tasks
125 | }
126 |
127 | export const UserStorySchema = z.object({
128 | id: z.number().int().nonnegative(),
129 | title: z.string().min(1).max(256), // Max length from KanbanItemModel or adjust
130 | description: z.string().optional().nullable(),
131 | acceptanceCriteria: z.string().optional().nullable(),
132 | priority: z.enum(KanbanPriorityValues),
133 | points: z.number().int().nonnegative().optional().nullable(),
134 | status: z.enum(KanbanStatusValues),
135 | createdAt: z.string().datetime({ offset: true }),
136 | taskIds: z.array(z.number().int()).optional(), // Array of task IDs
137 | });
138 |
--------------------------------------------------------------------------------
/python_backend/app.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | # ---------------------------------------------------------------------------
4 | # 🐍 Python 3.9 compatibility shim
5 | # ---------------------------------------------------------------------------
6 | import sys, dataclasses
7 | if sys.version_info < (3, 10): # Python < 3.10 has no dataclass slots
8 | _orig_dataclass = dataclasses.dataclass
9 |
10 | def _safe_dataclass(*args, **kwargs): # strip unsupported kwarg
11 | kwargs.pop("slots", None)
12 | return _orig_dataclass(*args, **kwargs)
13 |
14 | dataclasses.dataclass = _safe_dataclass # type: ignore[attr-defined]
15 |
16 | # ---------------------------------------------------------------------------
17 | # standard library
18 | # ---------------------------------------------------------------------------
19 | import os
20 | import logging
21 |
22 | # Ensure the parent directory of app.py (which is python_backend)
23 | # is at the beginning of sys.path, so modules like 'controllers', 'services', etc.
24 | # can be imported directly. This makes imports more robust.
25 | current_dir = os.path.dirname(os.path.abspath(__file__))
26 | if current_dir not in sys.path:
27 | sys.path.insert(0, current_dir)
28 |
29 | # third‑party
30 | from flask import Flask, jsonify, request
31 | from flask_cors import CORS
32 | from dotenv import load_dotenv # <-- Import load_dotenv
33 | from werkzeug.exceptions import HTTPException
34 |
35 | # local
36 | # These imports now rely on 'current_dir' being in sys.path
37 | from utils.response_utils import error_response
38 | from controllers import all_blueprints
39 |
40 | # --- Load environment variables from .env file ---
41 | # This should be called as early as possible
42 | load_dotenv()
43 | # -------------------------------------------------
44 |
45 | logging.basicConfig(
46 | level=logging.INFO,
47 | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
48 | )
49 | logger = logging.getLogger(__name__)
50 |
51 |
52 | def create_app(test_config=None):
53 | """Create and configure the Flask application."""
54 | # load_dotenv() # Moved to top level for earlier access
55 |
56 | flask_app = Flask(__name__, instance_relative_config=True) # Renamed to flask_app to avoid conflict
57 | flask_app.config.from_mapping(
58 | SECRET_KEY=os.environ.get("SECRET_KEY", "dev‑secret‑key"),
59 | DEBUG=os.environ.get("FLASK_DEBUG", "True").lower() == "true",
60 | )
61 |
62 | if test_config is None:
63 | flask_app.config.from_pyfile("config.py", silent=True)
64 | else:
65 | flask_app.config.from_mapping(test_config)
66 |
67 | os.makedirs(flask_app.instance_path, exist_ok=True)
68 |
69 | # ────────────────────────────────────────────────────────────────────
70 | # CORS – allow localhost **and** 127.0.0.1 on any port for /api/*
71 | # ────────────────────────────────────────────────────────────────────
72 | CORS(
73 | flask_app,
74 | resources={r"/api/*": {"origins": [r"http://localhost:*", r"http://127.0.0.1:*"]}},
75 | supports_credentials=False,
76 | )
77 |
78 | # ───── JSON error handlers ─────────────────────────────────────────
79 | @flask_app.errorhandler(404)
80 | def not_found_error(_):
81 | return error_response("Not Found", "The requested resource was not found.", 404)
82 |
83 | @flask_app.errorhandler(400)
84 | def bad_request_error(e):
85 | # If the exception has a description attribute (like Werkzeug exceptions), use it
86 | description = getattr(e, 'description', str(e))
87 | return error_response("Bad Request", description, 400)
88 |
89 | @flask_app.errorhandler(405)
90 | def method_not_allowed_error(_):
91 | return error_response("Method Not Allowed", "Method not allowed for this URL.", 405)
92 |
93 | @flask_app.errorhandler(Exception)
94 | def handle_exception(e):
95 | if isinstance(e, HTTPException):
96 | # Use the description from the HTTPException if available
97 | description = getattr(e, 'description', str(e))
98 | return error_response(e.name, description, e.code)
99 | logger.exception("Unhandled exception")
100 | return error_response("Internal Server Error", str(e), 500)
101 |
102 | # ───── Blueprint registration ──────────────────────────────────────
103 | for bp in all_blueprints:
104 | flask_app.register_blueprint(bp)
105 |
106 | @flask_app.get("/health")
107 | def health():
108 | return jsonify({"status": "healthy"}), 200
109 |
110 | logger.info("Flask application initialized successfully.")
111 | logger.info(f"Debug mode: {flask_app.config['DEBUG']}")
112 | # Log the API key presence (but not the key itself!)
113 | logger.info(f"OpenRouter API Key Loaded: {'Yes' if os.getenv('OPENROUTER_API_KEY') else 'No'}")
114 | return flask_app
115 |
116 | # Create the Flask app instance at the module level for Gunicorn
117 | app = create_app()
118 |
119 | def main():
120 | # For development, app.run() is used.
121 | # For production with Gunicorn, Gunicorn will use the 'app' instance defined above.
122 | app.run(
123 | host=os.environ.get("FLASK_HOST", "127.0.0.1"),
124 | port=int(os.environ.get("FLASK_PORT", 5010)),
125 | debug=app.config["DEBUG"],
126 | )
127 |
128 |
129 | if __name__ == "__main__":
130 | main()
--------------------------------------------------------------------------------
/python_backend/services/todo_service.py:
--------------------------------------------------------------------------------
1 | # File: python_backend/services/todo_service.py
2 | # NEW FILE
3 | import os
4 | import logging
5 | import time
6 | from typing import List, Dict, Any, Optional
7 | from services.service_exceptions import wrap_service_methods
8 | from repositories.file_storage import FileStorageRepository
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 | @wrap_service_methods
13 | class TodoService:
14 | """Service layer for managing TODO items."""
15 |
16 | def __init__(self, storage_repo: FileStorageRepository):
17 | self.storage_repo = storage_repo
18 | self.todo_filename = 'todos.json'
19 | self.codetoprompt_dir_name = '.codetoprompt'
20 | # Simple in-memory fallback (can be removed if file-based is always required)
21 | self._in_memory_db: Dict[int, Dict[str, Any]] = {
22 | 1: {'id': 1, 'text': 'Sample global todo', 'completed': False, 'createdAt': time.time()}
23 | }
24 | self._next_id = 2
25 |
26 | def _get_todo_file_path(self, project_path: Optional[str]) -> Optional[str]:
27 | """Gets the path to the todo file, returns None if no project path."""
28 | if not project_path:
29 | return None
30 | if not os.path.isdir(project_path):
31 | # Or raise ValueError depending on desired behavior
32 | logger.warning(f"Project path '{project_path}' is not a valid directory.")
33 | return None
34 | return os.path.join(project_path, self.codetoprompt_dir_name, self.todo_filename)
35 |
36 | def _load_todos(self, project_path: Optional[str]) -> List[Dict[str, Any]]:
37 | """Loads todos either from file or in-memory fallback."""
38 | file_path = self._get_todo_file_path(project_path)
39 | if file_path:
40 | try:
41 | data = self.storage_repo.read_json(file_path, default=[])
42 | if not isinstance(data, list):
43 | logger.warning(f"Todo file {file_path} is not a list. Returning empty.")
44 | return []
45 | # Basic validation of structure could be added here
46 | return data
47 | except Exception as e:
48 | logger.error(f"Failed to load todos for {project_path}: {e}")
49 | return [] # Return empty on error
50 | else:
51 | # In-memory fallback
52 | return list(self._in_memory_db.values())
53 |
54 | def _save_todos(self, project_path: Optional[str], todos: List[Dict[str, Any]]):
55 | """Saves todos either to file or updates in-memory fallback."""
56 | file_path = self._get_todo_file_path(project_path)
57 | if file_path:
58 | try:
59 | self.storage_repo.write_json(file_path, todos)
60 | except IOError as e:
61 | logger.error(f"Failed to save todos for {project_path}: {e}")
62 | raise # Re-raise IOErrors
63 | else:
64 | # In-memory fallback update
65 | self._in_memory_db = {item['id']: item for item in todos}
66 | # Update next_id if needed
67 | if todos:
68 | max_id = max(item['id'] for item in todos)
69 | self._next_id = max_id + 1
70 |
71 | def list_todos(self, project_path: Optional[str]) -> List[Dict[str, Any]]:
72 | """Lists all todos for the given project path (or global if None)."""
73 | return self._load_todos(project_path)
74 |
75 | def add_todo(self, text: str, project_path: Optional[str], created_at: Optional[str] = None) -> Dict[str, Any]:
76 | """Adds a new todo."""
77 | if not text:
78 | raise ValueError("Todo text cannot be empty.")
79 |
80 | todos = self._load_todos(project_path)
81 | new_id = int(time.time() * 1000) # Simple timestamp-based ID
82 |
83 | # Ensure ID is unique if using timestamp
84 | while any(t['id'] == new_id for t in todos):
85 | new_id += 1 # Increment if collision (unlikely but possible)
86 |
87 | new_item = {
88 | 'id': new_id,
89 | 'text': text,
90 | 'completed': False,
91 | 'createdAt': created_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
92 | }
93 | todos.append(new_item)
94 | self._save_todos(project_path, todos)
95 | return new_item
96 |
97 | def update_todo(self, todo_id: int, completed: bool, project_path: Optional[str]) -> Optional[Dict[str, Any]]:
98 | """Updates the completion status of a todo."""
99 | todos = self._load_todos(project_path)
100 | updated_item = None
101 | for item in todos:
102 | if item.get('id') == todo_id:
103 | item['completed'] = bool(completed)
104 | updated_item = item
105 | break
106 |
107 | if updated_item is None:
108 | return None # Not found
109 |
110 | self._save_todos(project_path, todos)
111 | return updated_item
112 |
113 | def delete_todo(self, todo_id: int, project_path: Optional[str]) -> bool:
114 | """Deletes a todo."""
115 | todos = self._load_todos(project_path)
116 | initial_length = len(todos)
117 | todos = [item for item in todos if item.get('id') != todo_id]
118 |
119 | if len(todos) == initial_length:
120 | return False # Not found
121 |
122 | self._save_todos(project_path, todos)
123 | return True
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // tailwind.config.js
2 | module.exports = {
3 | content: [
4 | "./pages/**/*.{js,ts,jsx,tsx}",
5 | "./views/**/*.{js,ts,jsx,tsx}",
6 | "./components/**/*.{js,ts,jsx,tsx}",
7 | ],
8 | darkMode: 'class',
9 | theme: {
10 | extend: {
11 | colors: {
12 | // Enhanced color palette
13 | primary: {
14 | lighter: 'rgba(130, 155, 255, 0.2)',
15 | light: 'rgba(130, 155, 255, 0.5)',
16 | DEFAULT: 'rgb(130, 155, 255)',
17 | dark: 'rgb(110, 135, 235)',
18 | },
19 | secondary: {
20 | lighter: 'rgba(85, 255, 130, 0.2)',
21 | light: 'rgba(85, 255, 130, 0.5)',
22 | DEFAULT: 'rgb(85, 255, 130)',
23 | dark: 'rgb(65, 235, 110)',
24 | },
25 | tertiary: {
26 | lighter: 'rgba(195, 155, 255, 0.2)',
27 | light: 'rgba(195, 155, 255, 0.5)',
28 | DEFAULT: 'rgb(195, 155, 255)',
29 | dark: 'rgb(175, 135, 235)',
30 | },
31 | accent: {
32 | pink: 'rgb(255, 125, 205)',
33 | cyan: 'rgb(145, 240, 255)',
34 | yellow: 'rgb(245, 255, 145)',
35 | orange: 'rgb(255, 190, 115)',
36 | },
37 | // Background shades
38 | bg: {
39 | primary: 'rgb(13, 14, 33)',
40 | secondary: 'rgb(20, 21, 44)',
41 | tertiary: 'rgb(28, 29, 58)',
42 | },
43 | },
44 | animation: {
45 | 'float': 'float 6s ease-in-out infinite',
46 | 'shimmer': 'shimmer 2.5s infinite',
47 | 'pulse-glow': 'pulseGlow 4s ease-in-out infinite',
48 | 'slide-up': 'slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
49 | 'slide-in-right': 'slideInRight 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
50 | 'fade-in': 'fadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
51 | 'background-shift': 'backgroundShift 10s ease infinite',
52 | },
53 | boxShadow: {
54 | 'glow-primary': '0 0 15px rgba(130, 155, 255, 0.4)',
55 | 'glow-secondary': '0 0 15px rgba(85, 255, 130, 0.4)',
56 | 'glow-tertiary': '0 0 15px rgba(195, 155, 255, 0.4)',
57 | 'card': '0 10px 30px rgba(0, 0, 0, 0.15), 0 3px 10px rgba(0, 0, 0, 0.1)',
58 | 'card-hover': '0 15px 40px rgba(0, 0, 0, 0.2), 0 5px 15px rgba(0, 0, 0, 0.15)',
59 | },
60 | backgroundImage: {
61 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
62 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
63 | 'gradient-angular': 'conic-gradient(var(--tw-gradient-stops))',
64 | },
65 | borderRadius: {
66 | 'xl': '0.75rem',
67 | '2xl': '1rem',
68 | '3xl': '1.5rem',
69 | },
70 | transitionDuration: {
71 | '400': '400ms',
72 | },
73 | keyframes: {
74 | float: {
75 | '0%, 100%': { transform: 'translateY(0)' },
76 | '50%': { transform: 'translateY(-7px)' },
77 | },
78 | shimmer: {
79 | '100%': { transform: 'translateX(100%)' },
80 | },
81 | pulseSlow: {
82 | '0%, 100%': { opacity: '1' },
83 | '50%': { opacity: '0.7' },
84 | },
85 | slideUp: {
86 | from: { transform: 'translateY(15px)', opacity: '0' },
87 | to: { transform: 'translateY(0)', opacity: '1' },
88 | },
89 | slideInRight: {
90 | from: { transform: 'translateX(15px)', opacity: '0' },
91 | to: { transform: 'translateX(0)', opacity: '1' },
92 | },
93 | backgroundShift: {
94 | '0%': { backgroundPosition: '0% 50%' },
95 | '50%': { backgroundPosition: '100% 50%' },
96 | '100%': { backgroundPosition: '0% 50%' },
97 | },
98 | },
99 | typography: {
100 | DEFAULT: {
101 | css: {
102 | maxWidth: '65ch',
103 | color: 'rgb(var(--color-text-primary))',
104 | h1: {
105 | color: 'rgb(var(--color-text-primary))',
106 | },
107 | h2: {
108 | color: 'rgb(var(--color-text-primary))',
109 | },
110 | h3: {
111 | color: 'rgb(var(--color-text-primary))',
112 | },
113 | h4: {
114 | color: 'rgb(var(--color-text-primary))',
115 | },
116 | strong: {
117 | color: 'rgb(var(--color-text-primary))',
118 | },
119 | a: {
120 | color: 'rgb(var(--color-primary))',
121 | '&:hover': {
122 | color: 'rgb(var(--color-tertiary))',
123 | },
124 | },
125 | code: {
126 | color: 'rgb(var(--color-secondary))',
127 | backgroundColor: 'rgba(var(--color-bg-secondary), 0.4)',
128 | paddingLeft: '0.25rem',
129 | paddingRight: '0.25rem',
130 | paddingTop: '0.125rem',
131 | paddingBottom: '0.125rem',
132 | borderRadius: '0.25rem',
133 | },
134 | pre: {
135 | backgroundColor: 'rgba(var(--color-bg-secondary), 0.7)',
136 | code: {
137 | backgroundColor: 'transparent',
138 | },
139 | },
140 | },
141 | },
142 | },
143 | backdropFilter: {
144 | 'none': 'none',
145 | 'blur': 'blur(4px)',
146 | 'blur-md': 'blur(8px)',
147 | 'blur-lg': 'blur(12px)',
148 | 'blur-xl': 'blur(16px)',
149 | 'blur-2xl': 'blur(24px)',
150 | 'blur-3xl': 'blur(32px)',
151 | },
152 | },
153 | fontFamily: {
154 | sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
155 | mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
156 | },
157 | },
158 | plugins: [
159 | require('@tailwindcss/typography'),
160 | require('tailwind-scrollbar'),
161 | ],
162 | }
--------------------------------------------------------------------------------
|