diff --git a/.gitignore b/.gitignore index 690e9ec..d8fa906 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ ui/coverage/ .npm/ .yarn/ .pnpm-store/ +ui/.vite/ +*.tgz +npm-debug.log* +yarn-debug.log* +yarn-error.log* # ── Environment & secrets ───────────────────────────────────────────────────── diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 89cf1b9..0000000 --- a/.npmignore +++ /dev/null @@ -1,12 +0,0 @@ -node_modules/ -ui/node_modules/ -coverage/ -ui/coverage/ -tests/ -specs/ -.specify/ -.agents/ -*.log -.DS_Store -.env -.env.* diff --git a/AGENTS.md b/AGENTS.md index bb5ce81..b1d7b32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # gitlocal Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-02 +Auto-generated from all feature plans. Last updated: 2026-04-04 ## Active Technologies - **Runtime**: Node.js 22+ (active LTS), TypeScript 5.x @@ -14,6 +14,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-02 - No database; runtime state is derived from the filesystem, git metadata, browser URL state, and in-memory UI state (004-copy-control-polish) - TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, Vite 7, react-markdown, rehype-highlight, highlight.js, Vitest, React Testing Library, esbuild (005-version-line-numbers) - No database; runtime state comes from local filesystem metadata, git metadata, build/package metadata, URL state, and in-memory server state (005-version-line-numbers) +- TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, @tanstack/react-query, Vite 7, Vitest, React Testing Library, esbuild (006-manual-file-editing) +- No database; runtime state is derived from the local filesystem, git metadata, browser URL state, and in-memory server/UI state (006-manual-file-editing) ## Project Structure @@ -39,9 +41,9 @@ npm run verify # Run tests, builds, and dependency audits TypeScript 5.x + Node.js 22+: follow standard conventions. Use `.js` extensions on all imports (NodeNext module resolution). No Go, no Makefile, no shell scripts. ## Recent Changes +- 006-manual-file-editing: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, @tanstack/react-query, Vite 7, Vitest, React Testing Library, esbuild - 005-version-line-numbers: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, Vite 7, react-markdown, rehype-highlight, highlight.js, Vitest, React Testing Library, esbuild - 004-copy-control-polish: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, highlight.js, Vitest, esbuild -- 004-copy-control-polish: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, highlight.js, Vitest, esbuild diff --git a/specs/006-manual-file-editing/checklists/requirements.md b/specs/006-manual-file-editing/checklists/requirements.md new file mode 100644 index 0000000..aea9c95 --- /dev/null +++ b/specs/006-manual-file-editing/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Manual Local File Editing + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass 1 completed with all checklist items satisfied. +- The specification intentionally bounds the feature to lightweight text-file operations and excludes IDE-scale editing workflows. diff --git a/specs/006-manual-file-editing/contracts/manual-file-operations.md b/specs/006-manual-file-editing/contracts/manual-file-operations.md new file mode 100644 index 0000000..414a8a1 --- /dev/null +++ b/specs/006-manual-file-editing/contracts/manual-file-operations.md @@ -0,0 +1,52 @@ +# Manual File Operations Contract + +## File Read Contract + +- `GET /api/file` continues to return the current file content payload for viewer rendering. +- For working-tree text files, the payload also includes: + - `editable`: whether inline editing is allowed in the current context. + - `revisionToken`: the snapshot token required for guarded update and delete requests. +- Non-text files and non-working-tree contexts must return `editable: false`. + +## Create File Contract + +- `POST /api/file` creates a new local file inside the opened repository. +- Request body: + - `path`: repository-relative target path. + - `content`: initial file content. +- Success behavior: + - Returns `ok: true`, `operation: "create"`, `status: "created"`, and the created `path`. +- Failure behavior: + - Blocks requests for existing paths, invalid paths, and paths outside the repository boundary. + +## Update File Contract + +- `PUT /api/file` saves manual edits to an existing local text file. +- Request body: + - `path`: repository-relative target path. + - `content`: replacement file content. + - `revisionToken`: snapshot token from the last successful read. +- Success behavior: + - Returns `ok: true`, `operation: "update"`, `status: "updated"`, and the updated `path`. +- Failure behavior: + - Returns `status: "conflict"` when the revision token no longer matches the on-disk file state. + - Returns a blocked or failed result for invalid paths, missing files, non-text files, or non-working-tree contexts. + +## Delete File Contract + +- `DELETE /api/file` removes an existing local file after UI confirmation. +- Request body: + - `path`: repository-relative target path. + - `revisionToken`: snapshot token from the last successful read. +- Success behavior: + - Returns `ok: true`, `operation: "delete"`, `status: "deleted"`, and the deleted `path`. +- Failure behavior: + - Returns `status: "conflict"` when the file changed after it was opened. + - Returns a blocked or failed result if the path is outside the repository boundary or cannot be removed. + +## UI Interaction Contract + +- Inline editing controls appear only for editable working-tree text files. +- New-file creation can be started from the repository view without leaving the app. +- Delete actions always require a confirmation step before the request is sent. +- After any successful create, update, or delete, the UI refreshes file and tree data from the server and updates the current selection accordingly. diff --git a/specs/006-manual-file-editing/data-model.md b/specs/006-manual-file-editing/data-model.md new file mode 100644 index 0000000..a70a684 --- /dev/null +++ b/specs/006-manual-file-editing/data-model.md @@ -0,0 +1,80 @@ +# Data Model: Manual Local File Editing + +## Editable File Snapshot + +- **Description**: The server-provided representation of a local text file that can be shown in the content panel and, when allowed, edited inline. +- **Fields**: + - `path`: Repository-relative file path. + - `content`: Current text content. + - `type`: Textual presentation type used by the viewer. + - `language`: Optional language hint for display. + - `editable`: Whether inline editing is allowed for this file in the current context. + - `revisionToken`: Snapshot token representing the file state at read time. +- **Validation rules**: + - `path` must stay inside the opened repository boundary. + - `editable` is false for non-text files and non-working-tree contexts. + - `revisionToken` must change when the underlying file content changes on disk. +- **Relationships**: + - Returned by the file-read contract. + - Used to initialize an inline edit session. + +## New File Draft + +- **Description**: The in-progress user input collected before creating a new file. +- **Fields**: + - `path`: Proposed repository-relative file path. + - `content`: Initial file content. +- **Validation rules**: + - `path` must not be empty. + - `path` must resolve inside the repository boundary. + - `path` must not already exist as a file or directory. +- **Relationships**: + - Submitted through the create-file contract. + - On success, becomes an editable file snapshot. + +## File Mutation Request + +- **Description**: A create, update, or delete action sent from the UI to the server. +- **Fields**: + - `operation`: `create`, `update`, or `delete`. + - `path`: Target repository-relative file path. + - `content`: New text content for create/update operations. + - `revisionToken`: Required for update and delete operations against an existing file snapshot. +- **Validation rules**: + - `create` requires `path` and allows optional empty `content`. + - `update` requires `path`, `content`, and `revisionToken`. + - `delete` requires `path`, `revisionToken`, and explicit user confirmation before the request is sent. +- **Relationships**: + - Processed by server-side file mutation handlers. + - Produces either a successful file operation result or a conflict/error result. + +## File Operation Result + +- **Description**: The normalized server response after a file mutation attempt. +- **Fields**: + - `ok`: Whether the operation succeeded. + - `operation`: `create`, `update`, or `delete`. + - `path`: The affected repository-relative path. + - `status`: `created`, `updated`, `deleted`, `conflict`, `blocked`, or `failed`. + - `message`: User-displayable outcome summary. +- **Validation rules**: + - Failed results must not imply a filesystem change when none occurred. + - Conflict results must be used when the stored `revisionToken` no longer matches the on-disk file snapshot. +- **Relationships**: + - Drives status messaging, query invalidation, and selection updates in the UI. + +## Inline Edit Session + +- **Description**: The UI-only state for a lightweight manual editing flow. +- **Fields**: + - `mode`: `view`, `edit`, `create`, or `confirm-delete`. + - `draftPath`: Current path input for file creation. + - `draftContent`: Current unsaved content. + - `dirty`: Whether the user has unsaved changes. + - `baseRevisionToken`: Revision token captured from the last successful file read. +- **Validation rules**: + - `dirty` becomes true after user changes content or a new-file path. + - Leaving a dirty session requires a warning or explicit discard action. +- **Relationships**: + - Initialized from an editable file snapshot or an empty new file draft. + - Cleared or reset after successful save, delete, or discard. diff --git a/specs/006-manual-file-editing/plan.md b/specs/006-manual-file-editing/plan.md new file mode 100644 index 0000000..5a9ff7c --- /dev/null +++ b/specs/006-manual-file-editing/plan.md @@ -0,0 +1,94 @@ +# Implementation Plan: Manual Local File Editing + +**Branch**: `006-manual-file-editing` | **Date**: 2026-04-04 | **Spec**: `specs/006-manual-file-editing/spec.md` +**Input**: Feature specification from `specs/006-manual-file-editing/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Add a lightweight manual editing workflow to GitLocal so users can make small local file changes without leaving the app: edit existing text files, create new files, and delete files safely. The implementation stays inside the current Hono + React architecture by extending the file handler surface with guarded working-tree mutation endpoints, teaching the working-tree tree view to reflect real filesystem state, and layering a small inline editor flow into the existing content panel with conflict and unsaved-change protection. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI +**Primary Dependencies**: Hono, @hono/node-server, React 18, @tanstack/react-query, Vite 7, Vitest, React Testing Library, esbuild +**Storage**: No database; runtime state is derived from the local filesystem, git metadata, browser URL state, and in-memory server/UI state +**Testing**: Vitest for backend and frontend, React Testing Library for UI workflows, Hono integration tests for file API behavior +**Target Platform**: Local desktop browser sessions on macOS, Windows, and Linux, served by the local Node.js process +**Project Type**: Local-first CLI application with a Node.js-served React single-page app +**Performance Goals**: File create, update, and delete actions should complete fast enough to feel immediate for ordinary text files, and the file tree should reflect successful changes without a full page reload +**Constraints**: Fully local runtime only, maintain at least 90% per-file coverage, keep generated artifacts repository-relative, preserve GitLocal's lightweight GitHub-like browsing experience, restrict mutation to the current working tree, and avoid expanding the feature into a multi-file IDE workflow +**Scale/Scope**: One open repository at a time, one active inline edit session in the content area, and lightweight text-file operations only for the current working-tree branch + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **TypeScript-First**: Pass. All planned server and UI work remains in the existing TypeScript codebase. +- **Test Coverage**: Pass. The plan includes backend handler tests, tree behavior tests, and UI workflow tests to preserve the ≥90% per-file requirement. +- **Fully Local**: Pass. File reads and mutations operate only on the local filesystem within the opened repository. +- **Node.js-Served React UI**: Pass. The feature extends the existing Hono-served SPA rather than changing the product architecture. +- **Clean & Useful UI**: Pass. The editing workflow stays focused on small manual interventions and avoids turning the app into a full IDE. +- **Free & Open Source**: Pass. No new proprietary or gated dependencies are introduced. +- **Repository-Relative Paths**: Pass. All generated planning artifacts use repository-relative paths. + +**Post-Design Check**: Pass. The design stays within the local TypeScript/React architecture, uses server-truth refresh after mutations, and explicitly bounds the UI to a single lightweight edit flow rather than IDE-scale editing. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-manual-file-editing/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── manual-file-operations.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── git/ +│ ├── repo.ts +│ └── tree.ts +├── handlers/ +│ └── files.ts +├── server.ts +└── types.ts + +tests/ +├── integration/ +│ └── server.test.ts +└── unit/ + └── handlers/ + └── files.test.ts + +ui/ +├── src/ +│ ├── App.css +│ ├── App.tsx +│ ├── services/ +│ │ └── api.ts +│ ├── types/ +│ │ └── index.ts +│ └── components/ +│ ├── ContentPanel/ +│ │ ├── ContentPanel.tsx +│ │ ├── ContentPanel.test.tsx +│ │ └── [new lightweight editor helpers as needed] +│ └── FileTree/ +│ ├── FileTree.tsx +│ └── FileTree.test.tsx +``` + +**Structure Decision**: Keep the feature inside the current single-project Hono + React layout. Server-side repository and path safety logic stays centralized in `src/git/` and `src/handlers/files.ts`, while the UI work remains in the existing content panel, file tree, shared API client, and app-level selection/query refresh flow. + +## Complexity Tracking + +No constitutional violations or exceptional complexity allowances are required for this feature. diff --git a/specs/006-manual-file-editing/quickstart.md b/specs/006-manual-file-editing/quickstart.md new file mode 100644 index 0000000..ae45544 --- /dev/null +++ b/specs/006-manual-file-editing/quickstart.md @@ -0,0 +1,25 @@ +# Quickstart: Manual Local File Editing + +## Prerequisites + +- Install dependencies with `npm ci` and `npm --prefix ui ci`. +- Start GitLocal against a local git repository that contains text files and at least one nested directory. + +## Validation Flow + +1. Start the app in a local repository and open a text file on the current branch. +2. Enter inline edit mode, change a small piece of text, save, and confirm the updated content is shown afterward. +3. Begin another edit, modify the content without saving, then navigate away and confirm GitLocal warns before discarding the unsaved change. +4. Create a new file in an existing folder, save it, and confirm the new file appears in the file tree and opens immediately. +5. Attempt to create a file at a path that already exists and confirm the app blocks the action with a clear explanation. +6. Delete an existing file, confirm the deletion in the confirmation step, and verify the file disappears from the tree and content view. +7. Trigger a delete flow and cancel it, then confirm the file remains available. +8. Change a file outside the app after opening it in edit mode, then try to save or delete from GitLocal and confirm the app reports a conflict instead of overwriting silently. +9. Open a binary or image file and confirm inline text editing is not offered. +10. Switch to a non-current branch view, if available, and confirm create, update, and delete actions are unavailable there. + +## Automated Checks + +- Run `npm test`. +- Run `npm run lint`. +- Run `npm run build`. diff --git a/specs/006-manual-file-editing/research.md b/specs/006-manual-file-editing/research.md new file mode 100644 index 0000000..14aa5b5 --- /dev/null +++ b/specs/006-manual-file-editing/research.md @@ -0,0 +1,33 @@ +# Research: Manual Local File Editing + +## Decision 1: Restrict file mutations to the current working tree only + +- **Decision**: Allow create, update, and delete actions only when the user is operating on the repository's current working-tree branch, and block mutation attempts for historical or non-current branch views. +- **Rationale**: Existing writeable file access in GitLocal comes from the local filesystem for the current branch, while non-current branches are read through git object data. Limiting mutations to the working tree avoids the complexity and risk of trying to write into historical git snapshots and matches the feature's purpose as a lightweight local editing aid. +- **Alternatives considered**: + - Allow edits on any viewed branch by writing to git objects or checking out branches behind the scenes: rejected because it would turn a small manual edit feature into branch-management behavior with much higher risk. + - Allow edits whenever a file is visible in the UI: rejected because files shown from non-current branches are not directly backed by the working tree. + +## Decision 2: Represent the working-tree browser from the filesystem, not tracked-git files alone + +- **Decision**: For the current working tree, list repository files and directories from the filesystem inside the repository root while continuing to exclude `.git` internals and paths outside the repository boundary. +- **Rationale**: The current tree helpers derive working-tree entries only from tracked git paths, which would hide newly created untracked files immediately after creation and make deletion refresh behavior inconsistent. A filesystem-backed view better matches the user's expectation that successful local file changes appear right away. +- **Alternatives considered**: + - Keep using tracked-git listings and wait for users to stage files externally: rejected because new files would not appear after a successful create operation, violating the spec. + - Maintain a client-side shadow list of created files: rejected because it would drift from the true local filesystem state and complicate refresh logic. + +## Decision 3: Use server-issued file revision tokens to guard update and delete actions + +- **Decision**: Include a lightweight revision token with editable file reads and require that token on update and delete requests so the server can reject stale operations when the file changed on disk after it was opened. +- **Rationale**: The spec explicitly calls out the edge case where a file changes on disk during editing. A revision token derived from the current file snapshot allows the server to detect this cleanly and return a conflict response instead of silently overwriting or deleting newer content. +- **Alternatives considered**: + - Always overwrite the file on save: rejected because it can destroy changes made by the user or an AI tool outside the app. + - Lock files while open in the UI: rejected because it is brittle across local tools and inconsistent with GitLocal's lightweight, local-first workflow. + +## Decision 4: Refresh the UI from server truth after each successful mutation instead of relying on optimistic local state + +- **Decision**: Treat the server as the source of truth after create, update, and delete actions by invalidating file and tree queries, clearing or redirecting stale selections when needed, and rebuilding any cached directory children from fresh responses. +- **Rationale**: The file tree currently caches expanded directory children in component state, and the content panel fetches file content through React Query. Server-truth refresh keeps the UI aligned with the actual filesystem state without inventing a second local mutation model. +- **Alternatives considered**: + - Optimistically patch file tree and content state in multiple UI components: rejected because it increases coupling and makes correctness harder, especially for nested directories and deletes. + - Force a full page reload after every mutation: rejected because it would be disruptive and out of step with the existing SPA behavior. diff --git a/specs/006-manual-file-editing/spec.md b/specs/006-manual-file-editing/spec.md new file mode 100644 index 0000000..797b5de --- /dev/null +++ b/specs/006-manual-file-editing/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: Manual Local File Editing + +**Feature Branch**: `006-manual-file-editing` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: User description: "Let's create an option to create/update/delete local files. The goal is not to replace an IDE, but instead for code development done primarily with tools like codex or claude code, add the ability to do minor changes manually." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Make a Small File Fix Inline (Priority: P1) + +When a user notices a small issue while browsing a repository in GitLocal, they can make a focused edit to an existing local file without leaving the app or switching to a full IDE. + +**Why this priority**: The core value of this feature is reducing context switching for quick corrections during AI-assisted development. Updating an existing file is the most common and immediate manual intervention. + +**Independent Test**: Open an existing editable local file, make a short content change, save it, and confirm the file reflects the update afterward. + +**Acceptance Scenarios**: + +1. **Given** a user is viewing an existing local text file, **When** they enter edit mode, modify the content, and save, **Then** the updated file content is written to that same local file. +2. **Given** a user has unsaved edits to an existing file, **When** they attempt to leave the editing flow, **Then** the system warns them before discarding those unsaved changes. +3. **Given** a user saves a valid change to an existing file, **When** the save completes, **Then** the interface shows that the edit succeeded and returns the user to a readable view of the updated file. +4. **Given** a user is viewing a non-current branch or historical file state, **When** they view that file, **Then** manual editing actions are unavailable for that non-working-tree context. + +--- + +### User Story 2 - Add a Missing File Quickly (Priority: P2) + +When a user realizes a small supporting file is missing, they can create a new local file from inside the app so they can keep momentum during a coding session. + +**Why this priority**: Creating a file is valuable for lightweight workflows, but it is secondary to editing because it happens less often and usually follows the same lightweight editing pattern. + +**Independent Test**: Start a file-creation flow, provide a valid new file path and initial content, save it, and confirm the new file appears in the repository view with the saved contents. + +**Acceptance Scenarios**: + +1. **Given** a user is browsing a local repository, **When** they choose to create a new file, enter a valid file path and content, and save, **Then** the new file is created in that local repository location. +2. **Given** a user chooses a file path that already exists, **When** they attempt to create the new file, **Then** the system prevents accidental overwrite and explains that the path is already in use. +3. **Given** a user successfully creates a new file, **When** the save completes, **Then** the new file is visible in the app and can be opened immediately. +4. **Given** a user provides a valid new file path whose parent folders do not yet exist inside the opened repository, **When** they save the file, **Then** the system creates the needed parent folders as part of the same successful file-creation action. + +--- + +### User Story 3 - Remove an Unneeded File Safely (Priority: P3) + +When a user identifies an obsolete or mistaken local file, they can delete it from inside the app with a clear confirmation step so cleanup does not require a separate tool. + +**Why this priority**: Deletion is useful for lightweight cleanup, but it is less frequent than editing and creation and carries more risk, so it follows after the primary authoring flows. + +**Independent Test**: Select an existing local file, trigger delete, confirm the deletion, and verify that the file is removed from the repository view and local filesystem. + +**Acceptance Scenarios**: + +1. **Given** a user selects an existing local file, **When** they choose delete and confirm the action, **Then** the system removes that file from the local repository and updates the visible file list. +2. **Given** a user selects delete for a file, **When** they cancel at the confirmation step, **Then** the file remains unchanged and available. +3. **Given** the system cannot complete a requested deletion, **When** the delete action fails, **Then** the user sees that the file was not removed and receives a clear failure message. + +### Edge Cases + +- What happens when a user tries to edit, create, or delete a path outside the currently opened local repository? +- How does the system handle an attempt to save or delete a file that changed on disk after the user opened it in the app? +- What happens when a user tries to create an empty-named file, a file in a missing parent path, or a path that resolves to a folder instead of a file? +- How does the system handle binary or otherwise non-text files that are not suitable for lightweight inline editing? +- What happens when the user starts creating a new file or editing an existing file and then refreshes, navigates away, or switches to another file before saving? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST allow users to open an inline editing flow for an existing local text file within the currently opened repository. +- **FR-002**: The system MUST allow users to save manual content changes back to that same local file. +- **FR-003**: The system MUST warn users before they lose unsaved manual edits through navigation, closing, refresh, or mode changes. +- **FR-003a**: Those unsaved-change warnings MAY be delivered through in-app confirmation UI for in-app navigation and browser-native confirmation behavior for page refresh or tab-close scenarios. +- **FR-004**: The system MUST allow users to start a new-file flow from within the currently opened local repository. +- **FR-005**: Users MUST be able to specify the intended file path and initial file content before creating a new file. +- **FR-006**: The system MUST prevent creation when the requested file path already exists as a file or folder and explain why the action was blocked. +- **FR-006a**: When a requested new file path is otherwise valid but includes missing parent folders within the currently opened repository, the system MUST create those parent folders as part of the successful file-creation flow. +- **FR-007**: The system MUST allow users to delete an existing local file from within the currently opened repository. +- **FR-008**: The system MUST require an explicit user confirmation step before permanently deleting a local file. +- **FR-009**: The system MUST limit create, update, and delete actions to files inside the currently opened local repository and MUST block attempts to act on paths outside that boundary. +- **FR-009a**: The system MUST allow create, update, and delete actions only for the currently opened repository's working-tree view and MUST NOT offer those mutation actions while the user is viewing a non-current branch or historical file state. +- **FR-010**: The system MUST provide clear success feedback after a file is created, updated, or deleted. +- **FR-011**: The system MUST provide clear failure feedback when a requested file action cannot be completed and MUST leave the existing file state unchanged when the action fails. +- **FR-012**: The system MUST refresh the visible repository/file view after a successful create, update, or delete so the user can immediately see the resulting file state. +- **FR-013**: The system MUST treat this feature as a lightweight manual editing aid and MUST NOT require users to manage multiple open files, complex project-wide editing workflows, or IDE-style editing sessions. +- **FR-014**: The system MUST only offer inline content editing for files that can be reasonably presented and modified as text in a lightweight editor. + +### Key Entities *(include if feature involves data)* + +- **Editable Local File**: A file inside the currently opened repository that can be shown and modified as lightweight text content. +- **New File Draft**: The in-progress file path and initial content a user enters before creating a new file. +- **Pending File Change**: Unsaved user-entered content modifications or a requested delete action awaiting confirmation or completion. +- **Repository Boundary**: The currently opened local repository scope that defines which file paths are allowed for manual create, update, and delete actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In validation testing, 100% of successful manual edits to existing text files are reflected in the local file content immediately after save. +- **SC-002**: In validation testing, 100% of successful new-file actions create the requested file in the selected repository location without overwriting an existing path. +- **SC-003**: In validation testing, 100% of delete actions require explicit confirmation before the file is removed. +- **SC-004**: In validation testing, 100% of attempted file actions targeting paths outside the opened repository are blocked. +- **SC-005**: In usability review, users can complete a small file create, update, or delete task without leaving the app for an external editor. + +## Assumptions + +- The feature is intended for local repositories already opened in GitLocal and does not need to support remote-only repositories. +- Lightweight editing is limited to minor text-based file work and does not need to match the breadth of a full IDE editing experience. +- The initial version can focus on whole-file editing rather than advanced editing tools such as multi-file tabs, refactor workflows, or live collaboration. +- Users still rely on tools such as Codex, Claude Code, or a full IDE for larger authoring tasks, with this feature covering only quick manual interventions. +- Missing parent folders for a valid new file path may be created automatically as part of file creation, as long as the resulting path stays inside the opened repository boundary. diff --git a/specs/006-manual-file-editing/tasks.md b/specs/006-manual-file-editing/tasks.md new file mode 100644 index 0000000..f437a48 --- /dev/null +++ b/specs/006-manual-file-editing/tasks.md @@ -0,0 +1,202 @@ +# Tasks: Manual Local File Editing + +**Input**: Design documents from `specs/006-manual-file-editing/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Include targeted backend and frontend tests because the implementation plan and project constitution require coverage-preserving validation for file API behavior and UI workflows. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare shared API and UI types for manual file operations. + +- [X] T001 Add manual file operation request/response and editable file metadata types in `src/types.ts` +- [X] T002 [P] Mirror manual file operation and editable file metadata types in `ui/src/types/index.ts` +- [X] T003 [P] Extend manual file operation client helpers in `ui/src/services/api.ts` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the shared server and tree infrastructure that all stories depend on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Add repository-boundary validation, text-edit eligibility checks, and revision-token helpers in `src/git/repo.ts` +- [X] T005 Rework working-tree directory listing to read repository filesystem entries safely in `src/git/tree.ts` +- [X] T006 Implement guarded `POST /api/file`, `PUT /api/file`, and `DELETE /api/file` handlers plus editable file metadata in `src/handlers/files.ts` +- [X] T007 Wire manual file operation routes into the server in `src/server.ts` +- [X] T008 [P] Add backend coverage for guarded file reads, create, update, delete, conflicts, and boundary failures in `tests/unit/handlers/files.test.ts` +- [X] T009 [P] Add file API integration coverage for manual file operations in `tests/integration/server.test.ts` +- [X] T010 Update tree refresh behavior for mutable working-tree views in `ui/src/components/FileTree/FileTree.tsx` +- [X] T011 [P] Add file tree coverage for newly created and deleted working-tree nodes in `ui/src/components/FileTree/FileTree.test.tsx` + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Make a Small File Fix Inline (Priority: P1) 🎯 MVP + +**Goal**: Let users edit an existing local text file inline, save safely, and avoid accidental loss of unsaved work. + +**Independent Test**: Open an editable working-tree text file, enter edit mode, make a small change, save it, and confirm the updated content is shown. Then make another unsaved change and confirm navigation warns before discard. + +### Implementation for User Story 1 + +- [X] T012 [P] [US1] Add lightweight inline editor UI helpers for editable file state and action controls in `ui/src/components/ContentPanel/InlineFileEditor.tsx` +- [X] T013 [US1] Integrate edit mode, dirty-state tracking, save flow, and unsaved-change protection into `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T014 [US1] Connect content-area status messaging and selection refresh after successful file updates in `ui/src/App.tsx` +- [X] T015 [P] [US1] Add inline editing styles for lightweight text editing states in `ui/src/App.css` +- [X] T016 [P] [US1] Add frontend coverage for edit mode, successful save, conflict handling, and unsaved-change warnings in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 1 should be independently functional and testable + +--- + +## Phase 4: User Story 2 - Add a Missing File Quickly (Priority: P2) + +**Goal**: Let users create a new text file inside the opened repository and immediately see and open it in the tree. + +**Independent Test**: Start the new-file flow, enter a valid repository-relative path and content, save, and confirm the file appears in the tree and opens. Attempt to reuse an existing path and confirm the action is blocked. + +### Implementation for User Story 2 + +- [X] T017 [P] [US2] Add new-file draft controls and path entry UI in `ui/src/components/ContentPanel/NewFileDraft.tsx` +- [X] T018 [US2] Integrate repository-scoped create-file flow, success selection, and duplicate-path error handling into `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T019 [US2] Update app-level selection and tree invalidation after file creation in `ui/src/App.tsx` +- [X] T020 [P] [US2] Add styles for new-file draft entry and validation feedback in `ui/src/App.css` +- [X] T021 [P] [US2] Add frontend coverage for successful file creation, immediate tree visibility, and existing-path rejection in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 2 should be independently functional and testable + +--- + +## Phase 5: User Story 3 - Remove an Unneeded File Safely (Priority: P3) + +**Goal**: Let users delete a local file with explicit confirmation and clear failure/conflict feedback. + +**Independent Test**: Open an editable working-tree file, trigger delete, confirm removal, and verify the file disappears from the tree and content view. Cancel the confirmation once and confirm the file remains. + +### Implementation for User Story 3 + +- [X] T022 [P] [US3] Add delete-confirmation UI for the content panel in `ui/src/components/ContentPanel/DeleteFileDialog.tsx` +- [X] T023 [US3] Integrate delete confirmation, cancel flow, conflict handling, and post-delete navigation in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T024 [US3] Update app-level selection clearing and status messaging after deletion in `ui/src/App.tsx` +- [X] T025 [P] [US3] Add styles for delete confirmation and destructive action states in `ui/src/App.css` +- [X] T026 [P] [US3] Add frontend coverage for confirmed deletion, canceled deletion, and stale-revision delete conflicts in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 3 should be independently functional and testable + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, cleanup, and feature-level verification across all stories. + +- [X] T027 Review manual file action affordances for non-editable files and non-current branches in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T028 [P] Refresh shared API mocks and supporting viewer test coverage for new manual file operation shapes in `ui/src/App.test.tsx` +- [X] T029 [P] Run the feature validation flow from `specs/006-manual-file-editing/quickstart.md` and capture any required follow-up fixes in `specs/006-manual-file-editing/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion +- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from User Story 1 content-panel editing scaffolding +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Story 1 content-panel action scaffolding +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational - no dependency on other stories +- **User Story 2 (P2)**: Can start after Foundational - reuses content-panel patterns from US1 but remains independently testable +- **User Story 3 (P3)**: Can start after Foundational - reuses content-panel patterns from US1 but remains independently testable + +### Within Each User Story + +- Shared UI helper components before content-panel integration +- Content-panel integration before app-level selection/status wiring +- UI behavior before final story-specific tests + +### Parallel Opportunities + +- `T002` and `T003` can run in parallel after `T001` +- `T008`, `T009`, and `T011` can run in parallel once the corresponding foundational behavior exists +- `T012`, `T015`, and `T016` can run in parallel within US1 after the foundational phase +- `T017`, `T020`, and `T021` can run in parallel within US2 after the foundational phase +- `T022`, `T025`, and `T026` can run in parallel within US3 after the foundational phase +- `T028` and `T029` can run in parallel during polish + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Add lightweight inline editor UI helpers for editable file state and action controls in ui/src/components/ContentPanel/InlineFileEditor.tsx" +Task: "Add inline editing styles for lightweight text editing states in ui/src/App.css" +Task: "Add frontend coverage for edit mode, successful save, conflict handling, and unsaved-change warnings in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +## Parallel Example: User Story 2 + +```bash +Task: "Add new-file draft controls and path entry UI in ui/src/components/ContentPanel/NewFileDraft.tsx" +Task: "Add styles for new-file draft entry and validation feedback in ui/src/App.css" +Task: "Add frontend coverage for successful file creation, immediate tree visibility, and existing-path rejection in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +## Parallel Example: User Story 3 + +```bash +Task: "Add delete-confirmation UI for the content panel in ui/src/components/ContentPanel/DeleteFileDialog.tsx" +Task: "Add styles for delete confirmation and destructive action states in ui/src/App.css" +Task: "Add frontend coverage for confirmed deletion, canceled deletion, and stale-revision delete conflicts in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. Stop and validate the inline edit flow independently before expanding scope + +### Incremental Delivery + +1. Complete Setup + Foundational to establish safe file mutation infrastructure +2. Deliver User Story 1 for existing-file editing as the MVP +3. Add User Story 2 for new-file creation with immediate tree refresh +4. Add User Story 3 for safe deletion with confirmation and conflict handling +5. Finish with cross-cutting validation and cleanup + +### Parallel Team Strategy + +1. One developer handles server mutation infrastructure in Phase 2 while another prepares UI type/client changes from Phase 1 +2. After Foundational completes: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Rejoin for polish and quickstart validation + +--- + +## Notes + +- [P] tasks are safe parallel opportunities because they target different files or follow completed shared dependencies +- Each user story phase is scoped to remain independently testable +- The suggested MVP scope is User Story 1 only +- All tasks follow the required checklist format with task ID, optional parallel marker, story label where needed, and exact file paths diff --git a/src/git/repo.ts b/src/git/repo.ts index 4f70227..0a5e138 100644 --- a/src/git/repo.ts +++ b/src/git/repo.ts @@ -1,8 +1,8 @@ import { spawnSync } from 'node:child_process' -import { basename, dirname, relative, resolve } from 'node:path' +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path' import { createHash } from 'node:crypto' -import { existsSync, readFileSync, statSync } from 'node:fs' -import type { RepoInfo, Branch, Commit } from '../types.js' +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import type { RepoInfo, Branch, Commit, TreeNode } from '../types.js' let cachedAppVersion = '' @@ -150,9 +150,30 @@ export function resolveRepoPath(repoPath: string, filePath: string): string { return resolve(repoPath, filePath) } +export function normalizeRepoRelativePath(filePath: string): string { + const normalized = filePath.replaceAll('\\', '/').trim() + if (!normalized) return '' + return normalized.replace(/^\.?\//, '').replace(/\/+/g, '/') +} + +export function isPathInsideRepo(repoPath: string, filePath: string): boolean { + const normalized = normalizeRepoRelativePath(filePath) + if (!normalized) return false + const resolvedPath = resolveRepoPath(repoPath, normalized) + const rel = relative(repoPath, resolvedPath) + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) +} + +export function resolveSafeRepoPath(repoPath: string, filePath: string): string | null { + if (!filePath) return repoPath + if (!isPathInsideRepo(repoPath, filePath)) return null + return resolveRepoPath(repoPath, normalizeRepoRelativePath(filePath)) +} + export function getPathType(repoPath: string, filePath: string): 'file' | 'dir' | 'missing' | 'none' { if (!filePath) return 'none' - const fullPath = resolveRepoPath(repoPath, filePath) + const fullPath = resolveSafeRepoPath(repoPath, filePath) + if (!fullPath) return 'missing' if (!existsSync(fullPath)) return 'missing' const stats = statSync(fullPath) return stats.isDirectory() ? 'dir' : 'file' @@ -161,7 +182,7 @@ export function getPathType(repoPath: string, filePath: string): 'file' | 'dir' export function nearestExistingRepoPath(repoPath: string, filePath: string): string { if (!filePath) return '' - let current = resolveRepoPath(repoPath, filePath) + let current = resolveRepoPath(repoPath, normalizeRepoRelativePath(filePath)) while (current.startsWith(resolve(repoPath))) { if (existsSync(current)) { const rel = relative(repoPath, current) @@ -230,13 +251,92 @@ export function getWorkingTreeRevision(repoPath: string): string { } export function readWorkingTreeFile(repoPath: string, filePath: string): Buffer | null { - const fullPath = resolveRepoPath(repoPath, filePath) + const fullPath = resolveSafeRepoPath(repoPath, filePath) + if (!fullPath) return null if (!existsSync(fullPath)) return null const stats = statSync(fullPath) if (stats.isDirectory()) return null return readFileSync(fullPath) } +export function isIgnoredPath(repoPath: string, filePath: string): boolean { + const normalized = normalizeRepoRelativePath(filePath) + if (!normalized) return false + const result = spawnSync('git', ['check-ignore', '-q', normalized], { cwd: repoPath }) + return result.status === 0 +} + +export function getEditableState(repoPath: string, filePath: string, branch: string): { editable: boolean; revisionToken: string | null } { + if (!isWorkingTreeBranch(repoPath, branch)) { + return { editable: false, revisionToken: null } + } + + const pathType = getPathType(repoPath, filePath) + if (pathType !== 'file') { + return { editable: false, revisionToken: null } + } + + const { type } = detectFileType(filePath) + return { + editable: type === 'markdown' || type === 'text', + revisionToken: getFileRevisionToken(repoPath, filePath), + } +} + +export function getFileRevisionToken(repoPath: string, filePath: string): string | null { + const rawBytes = readWorkingTreeFile(repoPath, filePath) + if (!rawBytes) return null + const hash = createHash('sha1') + hash.update(normalizeRepoRelativePath(filePath)) + hash.update(rawBytes) + return hash.digest('hex') +} + +export function writeWorkingTreeTextFile(repoPath: string, filePath: string, content: string): void { + const fullPath = resolveSafeRepoPath(repoPath, filePath) + if (!fullPath) { + throw new Error('Path must stay inside the opened repository.') + } + + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content, 'utf-8') +} + +export function deleteWorkingTreeFile(repoPath: string, filePath: string): void { + const fullPath = resolveSafeRepoPath(repoPath, filePath) + if (!fullPath) { + throw new Error('Path must stay inside the opened repository.') + } + + unlinkSync(fullPath) +} + +export function listWorkingTreeDirectoryEntries(repoPath: string, subpath: string = ''): TreeNode[] { + const normalized = normalizeRepoRelativePath(subpath) + const dirPath = normalized ? resolveSafeRepoPath(repoPath, normalized) : repoPath + + if (!dirPath || !existsSync(dirPath) || !statSync(dirPath).isDirectory()) { + return [] + } + + return readdirSync(dirPath, { withFileTypes: true }) + .filter((entry) => entry.name !== '.git') + .map((entry) => { + const path = normalized ? `${normalized}/${entry.name}` : entry.name + return { entry, path } + }) + .filter(({ path }) => !isIgnoredPath(repoPath, path)) + .map(({ entry, path }) => ({ + name: entry.name, + path, + type: entry.isDirectory() ? 'dir' as const : 'file' as const, + })) + .sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) +} + export function detectFileType(filename: string): { type: 'markdown' | 'text' | 'image' | 'binary'; language: string } { /* v8 ignore next */ const ext = filename.split('.').pop()?.toLowerCase() ?? '' diff --git a/src/git/tree.ts b/src/git/tree.ts index 0170737..5d8c7c3 100644 --- a/src/git/tree.ts +++ b/src/git/tree.ts @@ -2,7 +2,7 @@ import { statSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { spawnSync } from 'node:child_process' import type { TreeNode } from '../types.js' -import { getTrackedWorkingTreeFiles } from './repo.js' +import { getTrackedWorkingTreeFiles, listWorkingTreeDirectoryEntries } from './repo.js' function runLsTree(repoPath: string, args: string[]): string { const result = spawnSync('git', ['ls-tree', ...args], { cwd: repoPath, encoding: 'utf-8' }) @@ -70,29 +70,7 @@ function getTrackedWorkingTreeEntries(repoPath: string): TreeNode[] { } export function listWorkingTreeDir(repoPath: string, subpath: string = ''): TreeNode[] { - const prefix = subpath ? `${subpath}/` : '' - const depth = subpath ? subpath.split('/').length + 1 : 1 - const children = new Map() - - for (const entry of getTrackedWorkingTreeEntries(repoPath)) { - if (subpath) { - if (entry.path !== subpath && !entry.path.startsWith(prefix)) continue - } - if (!subpath && entry.path.includes('/')) { - const firstSegment = entry.path.split('/')[0] - if (entry.path !== firstSegment) continue - } - - const segments = entry.path.split('/') - if (segments.length !== depth) continue - - children.set(entry.path, entry) - } - - return Array.from(children.values()).sort((a, b) => { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 - return a.name.localeCompare(b.name) - }) + return listWorkingTreeDirectoryEntries(repoPath, subpath) } function readSnippet(filePath: string, query: string, caseSensitive: boolean): { line: number; snippet: string } | null { diff --git a/src/handlers/files.ts b/src/handlers/files.ts index 5123dfc..25ed815 100644 --- a/src/handlers/files.ts +++ b/src/handlers/files.ts @@ -1,16 +1,37 @@ import type { Context } from 'hono' import { spawnSync } from 'node:child_process' import { + deleteWorkingTreeFile, detectFileType, + getEditableState, getCurrentBranch, - getTrackedPathType, + getFileRevisionToken, + getPathType, isWorkingTreeBranch, + normalizeRepoRelativePath, readWorkingTreeFile, + writeWorkingTreeTextFile, } from '../git/repo.js' import { listDir, listWorkingTreeDir } from '../git/tree.js' +import type { FileContent, ManualFileMutationRequest, ManualFileOperationResult } from '../types.js' type Variables = { repoPath: string } +function mutationBlocked(operation: 'create' | 'update' | 'delete', path: string, message: string, status: number): Response { + const body: ManualFileOperationResult = { + ok: false, + operation, + path, + status: status === 409 ? 'conflict' : 'blocked', + message, + } + return Response.json(body, { status }) +} + +function parsePath(payload: ManualFileMutationRequest): string { + return normalizeRepoRelativePath(payload.path ?? '') +} + export async function treeHandler(c: Context<{ Variables: Variables }>): Promise { const repoPath = c.get('repoPath') if (!repoPath) return c.json([]) @@ -31,11 +52,7 @@ export async function fileHandler(c: Context<{ Variables: Variables }>): Promise const { type, language } = detectFileType(path) - if (type === 'binary') { - return c.json({ path, content: '', encoding: 'none', type: 'binary', language: '' }) - } - - if (isWorkingTreeBranch(repoPath, branch) && getTrackedPathType(repoPath, path) !== 'file') { + if (isWorkingTreeBranch(repoPath, branch) && getPathType(repoPath, path) !== 'file') { return c.json({ error: 'File not found' }, 404) } @@ -56,21 +73,169 @@ export async function fileHandler(c: Context<{ Variables: Variables }>): Promise return c.json({ error: 'File not found' }, 404) } + const editableState = getEditableState(repoPath, path, branch) + if (type === 'image') { - return c.json({ + const response: FileContent = { path, content: rawBytes.toString('base64'), encoding: 'base64', type: 'image', language: '', - }) + editable: false, + revisionToken: editableState.revisionToken, + } + return c.json(response) } - return c.json({ + if (type === 'binary') { + const response: FileContent = { + path, + content: '', + encoding: 'none', + type: 'binary', + language: '', + editable: false, + revisionToken: editableState.revisionToken, + } + return c.json(response) + } + + const response: FileContent = { path, content: rawBytes.toString('utf-8'), encoding: 'utf-8', type, language, - }) + editable: editableState.editable, + revisionToken: editableState.revisionToken, + } + return c.json(response) +} + +export async function createFileHandler(c: Context<{ Variables: Variables }>): Promise { + const repoPath = c.get('repoPath') + if (!repoPath) return c.json({ error: 'No repository loaded' }, 400) + + const branch = getCurrentBranch(repoPath) + if (!isWorkingTreeBranch(repoPath, branch)) { + return mutationBlocked('create', '', 'File creation is only available on the current working tree.', 409) + } + + const payload = (await c.req.json().catch(() => ({}))) as ManualFileMutationRequest + const path = parsePath(payload) + if (!path) { + return mutationBlocked('create', path, 'A repository-relative file path is required.', 400) + } + + if (payload.content !== undefined && typeof payload.content !== 'string') { + return mutationBlocked('create', path, 'File content must be text.', 400) + } + + if (getPathType(repoPath, path) !== 'missing') { + return mutationBlocked('create', path, 'That path already exists in the repository.', 409) + } + + try { + writeWorkingTreeTextFile(repoPath, path, payload.content ?? '') + const result: ManualFileOperationResult = { + ok: true, + operation: 'create', + path, + status: 'created', + message: 'File created successfully.', + } + return c.json(result, 201) + } catch (error) { + return mutationBlocked('create', path, error instanceof Error ? error.message : 'Failed to create the file.', 400) + } +} + +export async function updateFileHandler(c: Context<{ Variables: Variables }>): Promise { + const repoPath = c.get('repoPath') + if (!repoPath) return c.json({ error: 'No repository loaded' }, 400) + + const payload = (await c.req.json().catch(() => ({}))) as ManualFileMutationRequest + const path = parsePath(payload) + if (!path) { + return mutationBlocked('update', path, 'A repository-relative file path is required.', 400) + } + + if (typeof payload.content !== 'string') { + return mutationBlocked('update', path, 'Updated file content is required.', 400) + } + + if (!payload.revisionToken) { + return mutationBlocked('update', path, 'A file revision token is required to save changes.', 409) + } + + if (!isWorkingTreeBranch(repoPath, c.req.query('branch') ?? getCurrentBranch(repoPath))) { + return mutationBlocked('update', path, 'File updates are only available on the current working tree.', 409) + } + + if (getPathType(repoPath, path) !== 'file') { + return mutationBlocked('update', path, 'The selected file is no longer available.', 404) + } + + const { type } = detectFileType(path) + if (type !== 'markdown' && type !== 'text') { + return mutationBlocked('update', path, 'Only text files can be edited inline.', 400) + } + + const currentToken = getFileRevisionToken(repoPath, path) + if (!currentToken || currentToken !== payload.revisionToken) { + return mutationBlocked('update', path, 'The file changed on disk before your save completed.', 409) + } + + writeWorkingTreeTextFile(repoPath, path, payload.content) + const result: ManualFileOperationResult = { + ok: true, + operation: 'update', + path, + status: 'updated', + message: 'File updated successfully.', + } + return c.json(result) +} + +export async function deleteFileHandler(c: Context<{ Variables: Variables }>): Promise { + const repoPath = c.get('repoPath') + if (!repoPath) return c.json({ error: 'No repository loaded' }, 400) + + const payload = (await c.req.json().catch(() => ({}))) as ManualFileMutationRequest + const path = parsePath(payload) + if (!path) { + return mutationBlocked('delete', path, 'A repository-relative file path is required.', 400) + } + + if (!payload.revisionToken) { + return mutationBlocked('delete', path, 'A file revision token is required to delete a file.', 409) + } + + if (!isWorkingTreeBranch(repoPath, c.req.query('branch') ?? getCurrentBranch(repoPath))) { + return mutationBlocked('delete', path, 'File deletion is only available on the current working tree.', 409) + } + + if (getPathType(repoPath, path) !== 'file') { + return mutationBlocked('delete', path, 'The selected file is no longer available.', 404) + } + + const currentToken = getFileRevisionToken(repoPath, path) + if (!currentToken || currentToken !== payload.revisionToken) { + return mutationBlocked('delete', path, 'The file changed on disk before your delete completed.', 409) + } + + try { + deleteWorkingTreeFile(repoPath, path) + const result: ManualFileOperationResult = { + ok: true, + operation: 'delete', + path, + status: 'deleted', + message: 'File deleted successfully.', + } + return c.json(result) + } catch (error) { + return mutationBlocked('delete', path, error instanceof Error ? error.message : 'Failed to delete the file.', 400) + } } diff --git a/src/server.ts b/src/server.ts index fd8a818..1ec6896 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import { serveStatic } from '@hono/node-server/serve-static' import { join, resolve } from 'node:path' import { infoHandler, branchesHandler, commitsHandler, readmeHandler } from './handlers/git.js' -import { treeHandler, fileHandler } from './handlers/files.js' +import { treeHandler, fileHandler, createFileHandler, updateFileHandler, deleteFileHandler } from './handlers/files.js' import { pickBrowseHandler, pickHandler, pickParentHandler } from './handlers/pick.js' import { searchHandler } from './handlers/search.js' import { syncHandler } from './handlers/sync.js' @@ -75,6 +75,9 @@ export function createApp(initialRepoPath: string, options: CreateAppOptions = { app.get('/api/readme', readmeHandler) app.get('/api/tree', treeHandler) app.get('/api/file', fileHandler) + app.post('/api/file', createFileHandler) + app.put('/api/file', updateFileHandler) + app.delete('/api/file', deleteFileHandler) app.get('/api/search', searchHandler) app.get('/api/sync', syncHandler) app.get('/api/pick/browse', pickBrowseHandler) diff --git a/src/services/repo-watch.ts b/src/services/repo-watch.ts index 65f7c29..82664d4 100644 --- a/src/services/repo-watch.ts +++ b/src/services/repo-watch.ts @@ -1,8 +1,8 @@ import { - getTrackedPathType, + getPathType, getWorkingTreeRevision, isWorkingTreeBranch, - nearestExistingTrackedRepoPath, + nearestExistingRepoPath, } from '../git/repo.js' import type { SyncStatus } from '../types.js' @@ -24,11 +24,11 @@ export function getSyncStatus(repoPath: string, branch: string, currentPath: str } } - const currentPathType = getTrackedPathType(repoPath, currentPath) + const currentPathType = getPathType(repoPath, currentPath) const resolvedPath = currentPathType === 'missing' - ? nearestExistingTrackedRepoPath(repoPath, currentPath) + ? nearestExistingRepoPath(repoPath, currentPath) : currentPath - const resolvedPathType = getTrackedPathType(repoPath, resolvedPath) + const resolvedPathType = getPathType(repoPath, resolvedPath) const treeStatus = currentPathType === 'missing' ? 'invalid' : 'unchanged' const fileStatus = diff --git a/src/types.ts b/src/types.ts index f2fe178..dfce1da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,12 +37,34 @@ export interface TreeNode { type: 'file' | 'dir' } +export type FileEncoding = 'utf-8' | 'base64' | 'none' +export type FileContentType = 'markdown' | 'text' | 'image' | 'binary' + export interface FileContent { path: string content: string - encoding: 'utf-8' | 'base64' | 'none' - type: 'markdown' | 'text' | 'image' | 'binary' + encoding: FileEncoding + type: FileContentType language: string + editable: boolean + revisionToken: string | null +} + +export interface ManualFileMutationRequest { + path: string + content?: string + revisionToken?: string +} + +export type ManualFileOperation = 'create' | 'update' | 'delete' +export type ManualFileOperationStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'blocked' | 'failed' + +export interface ManualFileOperationResult { + ok: boolean + operation: ManualFileOperation + path: string + status: ManualFileOperationStatus + message: string } export interface PickRequest { diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 4bf33de..c347893 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -142,6 +142,49 @@ describe('Server integration', () => { expect(body.repoPath).toBe('') expect(body.fileStatus).toBe('unavailable') }) + + it('supports create, update, and delete through /api/file mutation routes', async () => { + const app = createApp(dir) + + const createRes = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'docs/notes.md', content: '# Draft' }), + })) + expect(createRes.status).toBe(201) + + const readCreated = await app.fetch(new Request('http://localhost/api/file?path=docs%2Fnotes.md')) + const createdBody = await readCreated.json() as { content: string; revisionToken: string } + expect(createdBody.content).toContain('# Draft') + expect(createdBody.revisionToken).toBeTruthy() + + const updateRes = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'docs/notes.md', + content: '# Updated Draft', + revisionToken: createdBody.revisionToken, + }), + })) + expect(updateRes.status).toBe(200) + + const reread = await app.fetch(new Request('http://localhost/api/file?path=docs%2Fnotes.md')) + const rereadBody = await reread.json() as { revisionToken: string } + + const deleteRes = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'docs/notes.md', + revisionToken: rereadBody.revisionToken, + }), + })) + expect(deleteRes.status).toBe(200) + + const missingRes = await app.fetch(new Request('http://localhost/api/file?path=docs%2Fnotes.md')) + expect(missingRes.status).toBe(404) + }) }) describe('Server startup path detection', () => { diff --git a/tests/unit/git/repo.test.ts b/tests/unit/git/repo.test.ts index 198c153..b56497b 100644 --- a/tests/unit/git/repo.test.ts +++ b/tests/unit/git/repo.test.ts @@ -20,6 +20,15 @@ import { nearestExistingRepoPath, nearestExistingTrackedRepoPath, readWorkingTreeFile, + normalizeRepoRelativePath, + isPathInsideRepo, + resolveSafeRepoPath, + isIgnoredPath, + getEditableState, + getFileRevisionToken, + writeWorkingTreeTextFile, + deleteWorkingTreeFile, + listWorkingTreeDirectoryEntries, } from '../../../src/git/repo.js' afterEach(() => { @@ -375,6 +384,19 @@ describe('working tree helpers', () => { } }) + it('normalizes and validates repository-relative paths', () => { + const { dir, cleanup } = makeGitRepo() + try { + expect(normalizeRepoRelativePath('./docs/guide.md')).toBe('docs/guide.md') + expect(normalizeRepoRelativePath('\\docs\\guide.md')).toBe('docs/guide.md') + expect(isPathInsideRepo(dir, 'docs/guide.md')).toBe(true) + expect(isPathInsideRepo(dir, '../outside.txt')).toBe(false) + expect(resolveSafeRepoPath(dir, '../outside.txt')).toBeNull() + } finally { + cleanup() + } + }) + it('returns tracked files and tracked path types without surfacing untracked files', () => { const { dir, cleanup } = makeGitRepo() try { @@ -396,4 +418,46 @@ describe('working tree helpers', () => { it('returns an empty tracked file list for invalid repositories', () => { expect(getTrackedWorkingTreeFiles(join(tmpdir(), 'definitely-missing-repo'))).toEqual([]) }) + + it('returns an empty tracked fallback when no tracked path exists', () => { + const { dir, cleanup } = makeGitRepo() + try { + expect(nearestExistingTrackedRepoPath(dir, 'missing/file.md')).toBe('') + } finally { + cleanup() + } + }) + + it('computes editable state and revision tokens for working-tree files only', () => { + const { dir, cleanup } = makeGitRepo() + try { + const branch = getCurrentBranch(dir) + const editable = getEditableState(dir, 'README.md', branch) + expect(editable.editable).toBe(true) + expect(editable.revisionToken).toBeTruthy() + expect(getEditableState(dir, 'missing.txt', branch)).toEqual({ editable: false, revisionToken: null }) + expect(getEditableState(dir, 'README.md', 'feature-missing')).toEqual({ editable: false, revisionToken: null }) + expect(getFileRevisionToken(dir, 'missing.txt')).toBeNull() + } finally { + cleanup() + } + }) + + it('writes, lists, ignores, and deletes working-tree files safely', () => { + const { dir, cleanup } = makeGitRepo() + try { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeWorkingTreeTextFile(dir, 'notes/new.md', 'hello') + expect(readWorkingTreeFile(dir, 'notes/new.md')?.toString('utf-8')).toBe('hello') + expect(listWorkingTreeDirectoryEntries(dir, 'notes').some((node) => node.path === 'notes/new.md')).toBe(true) + writeFileSync(join(dir, 'ignored.txt'), 'skip me') + expect(isIgnoredPath(dir, 'ignored.txt')).toBe(true) + deleteWorkingTreeFile(dir, 'notes/new.md') + expect(readWorkingTreeFile(dir, 'notes/new.md')).toBeNull() + expect(() => writeWorkingTreeTextFile(dir, '../escape.txt', 'x')).toThrow(/inside the opened repository/i) + expect(() => deleteWorkingTreeFile(dir, '../escape.txt')).toThrow(/inside the opened repository/i) + } finally { + cleanup() + } + }) }) diff --git a/tests/unit/git/tree.test.ts b/tests/unit/git/tree.test.ts index cf4b55e..2f22379 100644 --- a/tests/unit/git/tree.test.ts +++ b/tests/unit/git/tree.test.ts @@ -151,9 +151,9 @@ describe('working tree tree helpers', () => { } }) - it('does not surface untracked working-tree files', () => { + it('surfaces untracked working-tree files while leaving search behavior unchanged', () => { writeFileSync(join(dir, 'scratch.txt'), 'local-only') - expect(listWorkingTreeDir(dir, '').some((node) => node.path === 'scratch.txt')).toBe(false) + expect(listWorkingTreeDir(dir, '').some((node) => node.path === 'scratch.txt')).toBe(true) expect(searchWorkingTreeByName(dir, 'scratch', false)).toEqual([]) }) diff --git a/tests/unit/handlers/files.test.ts b/tests/unit/handlers/files.test.ts index 5b58ed6..6356c06 100644 --- a/tests/unit/handlers/files.test.ts +++ b/tests/unit/handlers/files.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { spawnSync } from 'node:child_process' @@ -91,13 +91,13 @@ describe('treeHandler', () => { expect(body.some((node: { name: string }) => node.name === 'feature.txt')).toBe(true) }) - it('excludes untracked files from the current branch tree', async () => { + it('includes untracked files in the current branch tree', async () => { writeFileSync(join(dir, 'local-only.txt'), 'draft') const app = createApp(dir) const client = testClient(app) const res = await client.api.tree.$get({ query: { path: '', branch } }) const body = await res.json() - expect(body.some((node: { name: string }) => node.name === 'local-only.txt')).toBe(false) + expect(body.some((node: { name: string }) => node.name === 'local-only.txt')).toBe(true) }) }) @@ -185,12 +185,294 @@ describe('fileHandler — 404 and error cases', () => { expect(res.status).toBe(404) }) - it('returns 404 for untracked files in the current branch', async () => { + it('returns untracked files from the current branch', async () => { writeFileSync(join(dir, 'scratch.txt'), 'local-only') const app = createApp(dir) const client = testClient(app) const res = await client.api.file.$get({ query: { path: 'scratch.txt', branch } }) - expect(res.status).toBe(404) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.content).toBe('local-only') + expect(body.editable).toBe(true) + }) +}) + +describe('manual file operation handlers', () => { + let dir: string + let branch: string + let cleanup: () => void + + beforeAll(() => { + const repo = makeGitRepo() + dir = repo.dir + branch = repo.branch + cleanup = repo.cleanup + }) + afterAll(() => cleanup()) + + it('creates a new file through POST /api/file', async () => { + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'notes/new-file.ts', content: 'export const created = true\n' }), + })) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.status).toBe('created') + + const readRes = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('notes/new-file.ts')}&branch=${branch}`)) + const readBody = await readRes.json() + expect(readBody.content).toContain('created = true') + }) + + it('updates a file through PUT /api/file with a matching revision token', async () => { + const app = createApp(dir) + const readRes = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('README.md')}&branch=${branch}`)) + const readBody = await readRes.json() + + const updateRes = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'README.md', + content: '# Updated', + revisionToken: readBody.revisionToken, + }), + })) + + expect(updateRes.status).toBe(200) + const updateBody = await updateRes.json() + expect(updateBody.status).toBe('updated') + }) + + it('rejects stale updates through PUT /api/file', async () => { + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'README.md', + content: '# stale', + revisionToken: 'stale-token', + }), + })) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.status).toBe('conflict') + }) + + it('rejects creates with an empty path or non-string content', async () => { + const app = createApp(dir) + + const missingPath = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '', content: 'x' }), + })) + expect(missingPath.status).toBe(400) + + const badContent = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'bad.json', content: 42 }), + })) + expect(badContent.status).toBe(400) + + const invalidJson = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + })) + expect(invalidJson.status).toBe(400) + }) + + it('rejects creates for existing or escaped paths', async () => { + const app = createApp(dir) + + const existing = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'README.md', content: 'x' }), + })) + expect(existing.status).toBe(409) + + const escaped = await app.fetch(new Request('http://localhost/api/file', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '../escape.txt', content: 'x' }), + })) + expect(escaped.status).toBe(400) + }) + + it('deletes a file through DELETE /api/file with a matching revision token', async () => { + writeFileSync(join(dir, 'delete-me.txt'), 'temporary') + const app = createApp(dir) + const readRes = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('delete-me.txt')}&branch=${branch}`)) + const readBody = await readRes.json() + + const deleteRes = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'delete-me.txt', + revisionToken: readBody.revisionToken, + }), + })) + + expect(deleteRes.status).toBe(200) + const deleteBody = await deleteRes.json() + expect(deleteBody.status).toBe('deleted') + }) + + it('rejects updates without required fields or for non-text files', async () => { + const app = createApp(dir) + + const invalidJson = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: '{', + })) + expect(invalidJson.status).toBe(400) + + const missingPath = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '', content: '# missing', revisionToken: 'token' }), + })) + expect(missingPath.status).toBe(400) + + const missingContent = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'README.md' }), + })) + expect(missingContent.status).toBe(400) + + const missingToken = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'README.md', content: '# Updated again' }), + })) + expect(missingToken.status).toBe(409) + + const imageRead = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('logo.png')}&branch=${branch}`)) + const imageBody = await imageRead.json() + const nonText = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'logo.png', + content: 'nope', + revisionToken: imageBody.revisionToken, + }), + })) + expect(nonText.status).toBe(400) + + const missingFile = await app.fetch(new Request('http://localhost/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'missing.txt', + content: 'missing', + revisionToken: 'token', + }), + })) + expect(missingFile.status).toBe(404) + }) + + it('blocks updates and deletes on non-current branches', async () => { + const app = createApp(dir) + const readRes = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('README.md')}&branch=${branch}`)) + const readBody = await readRes.json() + + const updateRes = await app.fetch(new Request('http://localhost/api/file?branch=feature-files', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'README.md', + content: '# blocked', + revisionToken: readBody.revisionToken, + }), + })) + expect(updateRes.status).toBe(409) + + const deleteRes = await app.fetch(new Request('http://localhost/api/file?branch=feature-files', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'README.md', + revisionToken: readBody.revisionToken, + }), + })) + expect(deleteRes.status).toBe(409) + }) + + it('rejects deletes without a revision token or for missing files', async () => { + const app = createApp(dir) + + const invalidJson = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: '{', + })) + expect(invalidJson.status).toBe(400) + + const missingPath = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '', revisionToken: 'token' }), + })) + expect(missingPath.status).toBe(400) + + const missingToken = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'README.md' }), + })) + expect(missingToken.status).toBe(409) + + const missingFile = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'missing.md', revisionToken: 'missing-token' }), + })) + expect(missingFile.status).toBe(404) + }) + + it('rejects stale deletes and surfaces filesystem delete failures', async () => { + writeFileSync(join(dir, 'locked.txt'), 'locked') + const app = createApp(dir) + const readRes = await app.fetch(new Request(`http://localhost/api/file?path=${encodeURIComponent('locked.txt')}&branch=${branch}`)) + const readBody = await readRes.json() + + const staleDelete = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'locked.txt', + revisionToken: 'stale-token', + }), + })) + expect(staleDelete.status).toBe(409) + + chmodSync(dir, 0o500) + try { + const failedDelete = await app.fetch(new Request('http://localhost/api/file', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: 'locked.txt', + revisionToken: readBody.revisionToken, + }), + })) + expect(failedDelete.status).toBe(400) + } finally { + chmodSync(dir, 0o700) + rmSync(join(dir, 'locked.txt'), { force: true }) + } }) }) @@ -216,6 +498,8 @@ describe('fileHandler', () => { expect(body.type).toBe('markdown') expect(body.encoding).toBe('utf-8') expect(body.content).toContain('Hello') + expect(body.editable).toBe(true) + expect(body.revisionToken).toBeTruthy() }) it('returns text type with language for .ts files', async () => { @@ -225,6 +509,7 @@ describe('fileHandler', () => { const body = await res.json() expect(body.type).toBe('text') expect(body.language).toBe('typescript') + expect(body.editable).toBe(true) }) it('returns image type with base64 encoding for .png files', async () => { @@ -243,6 +528,8 @@ describe('fileHandler', () => { const res = await client.api.file.$get({ query: { path: 'feature.txt', branch: 'feature-files' } }) const body = await res.json() expect(body.content).toContain('feature branch file') + expect(body.editable).toBe(false) + expect(body.revisionToken).toBeNull() }) it('uses HEAD branch and empty path when params are absent', async () => { diff --git a/tests/unit/services/repo-watch.test.ts b/tests/unit/services/repo-watch.test.ts index ca5ff6e..b0227a7 100644 --- a/tests/unit/services/repo-watch.test.ts +++ b/tests/unit/services/repo-watch.test.ts @@ -73,8 +73,8 @@ describe('repo-watch', () => { const status = getSyncStatus(dir, branch, 'docs/missing/file.md') expect(status.fileStatus).toBe('deleted') expect(status.treeStatus).toBe('invalid') - expect(status.resolvedPath).toBe('') - expect(status.resolvedPathType).toBe('none') + expect(status.resolvedPath).toBe('docs') + expect(status.resolvedPathType).toBe('dir') } finally { cleanup() } diff --git a/ui/src/App.css b/ui/src/App.css index 04f6d5d..20274ae 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -659,6 +659,14 @@ body { font-size: 14px; } +.content-empty-stack { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + text-align: center; +} + .content-toolbar { display: flex; gap: 8px; @@ -678,6 +686,26 @@ body { color: var(--color-text); } +.btn-primary { + background: #0969da; + border-color: #0969da; + color: #fff; +} + +.btn-primary:hover { + background: #0858b8; + border-color: #0858b8; +} + +.btn-danger { + border-color: #cf222e; + color: #cf222e; +} + +.btn-danger:hover { + background: rgba(207, 34, 46, 0.08); +} + .btn-raw:hover, .copy-button:hover { background: var(--color-hover); } @@ -731,6 +759,84 @@ body { padding: 24px 0; } +.manual-editor-card { + display: flex; + flex-direction: column; + gap: 16px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #fff; + padding: 18px; +} + +.manual-editor-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.manual-editor-header h3 { + margin: 0 0 4px; + font-size: 16px; + color: var(--color-text); +} + +.manual-editor-header p, +.manual-delete-copy { + margin: 0; + color: var(--color-text-muted); + font-size: 13px; +} + +.manual-editor-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.manual-editor-field { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--color-text); + font-size: 13px; +} + +.manual-editor-input, +.manual-editor-textarea { + width: 100%; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 10px 12px; + font: inherit; + color: var(--color-text); + background: #fff; +} + +.manual-editor-input:focus-visible, +.manual-editor-textarea:focus-visible { + outline: 2px solid rgba(9, 105, 218, 0.35); + outline-offset: 2px; +} + +.manual-editor-textarea { + min-height: 340px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + line-height: 1.5; +} + +.manual-editor-error { + margin: 0; + color: #cf222e; + font-size: 13px; +} + +.manual-delete-card { + max-width: 620px; +} + .content-image { max-width: 100%; border: 1px solid var(--color-border); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 592956e..be75a44 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -15,6 +15,9 @@ vi.mock('./services/api', () => ({ getBranches: vi.fn(), getCommits: vi.fn(), getFile: vi.fn(), + createFile: vi.fn(), + updateFile: vi.fn(), + deleteFile: vi.fn(), getSearchResults: vi.fn(), getPickBrowse: vi.fn(), submitPick: vi.fn(), @@ -72,7 +75,9 @@ describe('App', () => { type: 'text', content: 'hello', language: 'markdown', - encoding: 'utf8', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-docs', }) vi.mocked(api.getSearchResults).mockResolvedValue({ query: 'hello', @@ -288,7 +293,9 @@ describe('App', () => { type: 'markdown', content: '# hello', language: '', - encoding: 'utf8', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-readme', }) renderWithClient() @@ -315,7 +322,9 @@ describe('App', () => { type: 'text', content: '# hello', language: 'markdown', - encoding: 'utf8', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-readme', }) renderWithClient() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2dc7bc0..f519d95 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -39,6 +39,8 @@ export default function App() { const [readmeMissing, setReadmeMissing] = useState(false) const [pickerLoading, setPickerLoading] = useState(false) const [statusMessage, setStatusMessage] = useState('') + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [treeRefreshToken, setTreeRefreshToken] = useState(0) const queryClient = useQueryClient() const lastRevisionRef = useRef('') @@ -187,7 +189,24 @@ export default function App() { } }, [syncStatus, queryClient]) + useEffect(() => { + const handler = (event: BeforeUnloadEvent) => { + if (!hasUnsavedChanges) return + event.preventDefault() + event.returnValue = '' + } + + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [hasUnsavedChanges]) + + function confirmDiscardChanges(): boolean { + if (!hasUnsavedChanges) return true + return window.confirm('Discard your unsaved file changes?') + } + function handleSelectFile(path: string) { + if (!confirmDiscardChanges()) return setSelectedPath(path) setSelectedPathType(path ? 'file' : 'none') setStatusMessage('') @@ -195,6 +214,7 @@ export default function App() { } function handleSelectFolder(path: string) { + if (!confirmDiscardChanges()) return setSelectedPath(path) setSelectedPathType(path ? 'dir' : 'none') setStatusMessage('') @@ -264,6 +284,23 @@ export default function App() { setSearchQuery('') } + const canMutateFiles = Boolean(info?.isGitRepo && !hasRepoMismatch && info.currentBranch && currentBranch === info.currentBranch) + + async function handleMutationComplete(event: { + nextPath: string + nextPathType: SelectedPathType + result: { message: string } + }) { + setHasUnsavedChanges(false) + setSelectedPath(event.nextPath) + setSelectedPathType(event.nextPathType) + setShowRaw(false) + setStatusMessage(event.result.message) + setTreeRefreshToken((value) => value + 1) + await queryClient.invalidateQueries({ queryKey: ['tree'] }) + await queryClient.invalidateQueries({ queryKey: ['sync'] }) + } + const visibleSelectedPath = hasRepoMismatch ? '' : selectedPath const visibleSelectedPathType: SelectedPathType = hasRepoMismatch ? 'none' : selectedPathType const visibleShowRaw = hasRepoMismatch ? false : showRaw @@ -316,6 +353,7 @@ export default function App() { { + if (!confirmDiscardChanges()) return + setCurrentBranch(nextBranch) + }} /> )} @@ -370,13 +411,18 @@ export default function App() { }} /> { void handleMutationComplete(event) }} placeholder={noReadmePlaceholder} raw={visibleShowRaw} onRawChange={setShowRaw} + onStatusMessage={setStatusMessage} /> diff --git a/ui/src/components/ContentPanel/ContentPanel.test.tsx b/ui/src/components/ContentPanel/ContentPanel.test.tsx index 31cd242..54dcfee 100644 --- a/ui/src/components/ContentPanel/ContentPanel.test.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { vi, describe, it, expect, beforeEach } from 'vitest' import { axe } from 'jest-axe' @@ -7,6 +7,9 @@ import ContentPanel from './ContentPanel' vi.mock('../../services/api', () => ({ api: { getFile: vi.fn(), + createFile: vi.fn(), + updateFile: vi.fn(), + deleteFile: vi.fn(), }, })) @@ -41,46 +44,199 @@ function makeClient() { function renderWithClient(ui: React.ReactElement, client?: QueryClient) { const queryClient = client ?? makeClient() - return render( - {ui} - ) + return render({ui}) +} + +function makeTextFile(overrides: Partial>> = {}) { + return { + path: 'README.md', + type: 'text' as const, + content: 'hello', + language: 'markdown', + encoding: 'utf-8' as const, + editable: true, + revisionToken: 'rev-1', + ...overrides, + } } describe('ContentPanel', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(window, 'confirm').mockReturnValue(true) }) it('shows empty state when no filePath', () => { const { container } = renderWithClient( - + , ) expect(screen.getByText(/Select a file/i)).toBeInTheDocument() return expect(axe(container)).resolves.toMatchObject({ violations: [] }) }) + it('offers a create action from the empty state when mutation is allowed', async () => { + vi.mocked(api.createFile).mockResolvedValue({ + ok: true, + operation: 'create', + path: 'docs/new.md', + status: 'created', + message: 'File created successfully.', + }) + + const onMutationComplete = vi.fn() + const onStatusMessage = vi.fn() + + renderWithClient( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /new file/i })) + fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'docs/new.md' } }) + fireEvent.change(screen.getByLabelText(/new file content/i), { target: { value: '# Draft' } }) + fireEvent.click(screen.getByRole('button', { name: /create file/i })) + + await waitFor(() => { + expect(api.createFile).toHaveBeenCalledWith({ path: 'docs/new.md', content: '# Draft' }) + }) + expect(onStatusMessage).toHaveBeenCalledWith('File created successfully.') + expect(onMutationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + nextPath: 'docs/new.md', + nextPathType: 'file', + }), + ) + }) + + it('shows create errors and supports returning from create mode', async () => { + vi.mocked(api.createFile).mockRejectedValue({ error: 'That path already exists in the repository.' }) + + renderWithClient( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /new file/i })) + fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'docs/new.md' } }) + fireEvent.click(screen.getByRole('button', { name: /create file/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/already exists/i) + + fireEvent.click(screen.getByRole('button', { name: /back to viewer/i })) + expect(screen.getByText(/select a file/i)).toBeInTheDocument() + }) + + it('shows folder placeholder and supports creating a file in that folder', async () => { + vi.mocked(api.createFile).mockResolvedValue({ + ok: true, + operation: 'create', + path: 'docs/guide.md', + status: 'created', + message: 'File created successfully.', + }) + + renderWithClient( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /new file here/i })) + expect(screen.getByLabelText(/new file path/i)).toHaveValue('docs/') + }) + + it('cancels create mode from the draft form', async () => { + renderWithClient( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /new file here/i })) + fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })) + + expect(screen.getByText(/browse files inside/i)).toBeInTheDocument() + }) + it('shows loading skeleton while fetching', async () => { - // Never resolves vi.mocked(api.getFile).mockReturnValue(new Promise(() => {})) renderWithClient( - + , ) expect(screen.getByLabelText('loading content')).toBeInTheDocument() }) + it('shows an error state when file loading fails', async () => { + vi.mocked(api.getFile).mockRejectedValue(new Error('boom')) + + renderWithClient( + , + ) + + expect(await screen.findByText(/failed to load file/i)).toBeInTheDocument() + }) + it('shows markdown renderer for markdown files', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'README.md', - type: 'markdown', - content: '# Hello', - language: '', - encoding: 'utf8', - }) + vi.mocked(api.getFile).mockResolvedValue( + makeTextFile({ type: 'markdown', language: '', content: '# Hello' }), + ) renderWithClient( - + , ) await waitFor(() => { @@ -88,54 +244,78 @@ describe('ContentPanel', () => { }) }) - it('shows code viewer for text files', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'main.go', - type: 'text', - language: 'go', - content: 'package main', - encoding: 'utf8', - }) + it('shows code viewer for text files and supports raw mode', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile({ path: 'main.go', language: 'go', content: 'package main' })) + const onRawChange = vi.fn() renderWithClient( - + , ) await waitFor(() => { expect(screen.getByTestId('code-viewer')).toBeInTheDocument() }) + expect(screen.getByTestId('line-number-gutter')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /view raw/i })) + expect(onRawChange).toHaveBeenCalledWith(true) }) - it('shows binary placeholder for binary files', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'image.bin', - type: 'binary', - content: '', - language: '', - encoding: 'base64', - }) - - renderWithClient( - + it('shows binary and image presentations without inline editing', async () => { + vi.mocked(api.getFile) + .mockResolvedValueOnce({ + path: 'image.bin', + type: 'binary', + content: '', + language: '', + encoding: 'none', + editable: false, + revisionToken: 'rev-bin', + }) + .mockResolvedValueOnce({ + path: 'photo.png', + type: 'image', + encoding: 'base64', + content: 'abc123', + language: '', + editable: false, + revisionToken: 'rev-image', + }) + + const { rerender } = renderWithClient( + , ) await waitFor(() => { expect(screen.getByText(/Binary file/i)).toBeInTheDocument() }) - }) - - it('shows image for image files', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'photo.png', - type: 'image', - encoding: 'base64', - content: 'abc123', - language: '', - }) - renderWithClient( - + rerender( + + + , ) await waitFor(() => { @@ -145,107 +325,239 @@ describe('ContentPanel', () => { }) }) - it('View Raw button toggles rendering', async () => { - vi.mocked(api.getFile).mockResolvedValue({ + it('enters edit mode, tracks dirty state, and saves updates', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile({ content: 'original text' })) + vi.mocked(api.updateFile).mockResolvedValue({ + ok: true, + operation: 'update', path: 'README.md', - type: 'markdown', - content: '# Hello', - language: '', - encoding: 'utf8', + status: 'updated', + message: 'File updated successfully.', }) + const onDirtyChange = vi.fn() + const onMutationComplete = vi.fn() + renderWithClient( - + , ) + await screen.findByRole('button', { name: /edit file/i }) + fireEvent.click(screen.getByRole('button', { name: /edit file/i })) + fireEvent.change(screen.getByLabelText(/edit file content/i), { target: { value: 'updated text' } }) + await waitFor(() => { - expect(screen.getByTestId('markdown-renderer')).toBeInTheDocument() + expect(onDirtyChange).toHaveBeenCalledWith(true) }) - const rawButton = screen.getByText('View Raw') - fireEvent.click(rawButton) + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) await waitFor(() => { - expect(screen.getByTestId('code-viewer')).toBeInTheDocument() + expect(api.updateFile).toHaveBeenCalledWith({ + path: 'README.md', + content: 'updated text', + revisionToken: 'rev-1', + }) }) - expect(screen.getByTestId('line-number-gutter')).toBeInTheDocument() + expect(onMutationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + nextPath: 'README.md', + nextPathType: 'file', + }), + ) + }) - expect(screen.queryByTestId('markdown-renderer')).not.toBeInTheDocument() + it('shows update conflicts inline when save fails', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile()) + vi.mocked(api.updateFile).mockRejectedValue({ error: 'The file changed on disk before your save completed.' }) + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /edit file/i })) + fireEvent.change(screen.getByLabelText(/edit file content/i), { target: { value: 'updated' } }) + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/changed on disk/i) }) - it('shows a raw copy action and copies the full file content', async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - value: { writeText }, - configurable: true, - }) + it('warns before discarding dirty edits', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile()) + vi.mocked(window.confirm).mockReturnValue(false) + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /edit file/i })) + fireEvent.change(screen.getByLabelText(/edit file content/i), { target: { value: 'dirty' } }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + + expect(screen.getByLabelText(/edit file content/i)).toBeInTheDocument() + }) + + it('cancels edit mode after confirmation', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile()) + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /edit file/i })) + fireEvent.change(screen.getByLabelText(/edit file content/i), { target: { value: 'dirty' } }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + + expect(screen.queryByLabelText(/edit file content/i)).not.toBeInTheDocument() + }) - vi.mocked(api.getFile).mockResolvedValue({ + it('confirms and deletes a file', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile()) + vi.mocked(api.deleteFile).mockResolvedValue({ + ok: true, + operation: 'delete', path: 'README.md', - type: 'markdown', - content: '# Hello', - language: '', - encoding: 'utf8', + status: 'deleted', + message: 'File deleted successfully.', }) + const onMutationComplete = vi.fn() + renderWithClient( - + , ) + fireEvent.click(await screen.findByRole('button', { name: /delete file/i })) + const dialog = screen.getByRole('alertdialog') + expect(dialog).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('button', { name: /^delete file$/i })) + await waitFor(() => { - expect(screen.getByText('View Raw')).toBeInTheDocument() + expect(api.deleteFile).toHaveBeenCalledWith({ + path: 'README.md', + revisionToken: 'rev-1', + }) }) + expect(onMutationComplete).toHaveBeenCalledWith( + expect.objectContaining({ + nextPath: '', + nextPathType: 'none', + }), + ) + }) - fireEvent.click(screen.getByText('View Raw')) + it('shows delete errors and allows canceling the confirmation state', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile()) + vi.mocked(api.deleteFile).mockRejectedValue({ error: 'The file changed on disk before your delete completed.' }) - await waitFor(() => { - expect(screen.getByRole('button', { name: /copy raw file/i })).toBeInTheDocument() - }) + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /delete file/i })) + const dialog = screen.getByRole('alertdialog') + fireEvent.click(within(dialog).getByRole('button', { name: /^delete file$/i })) - fireEvent.click(screen.getByRole('button', { name: /copy raw file/i })) - expect(writeText).toHaveBeenCalledWith('# Hello') + expect(await screen.findByRole('alert')).toHaveTextContent(/delete completed/i) + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: /^cancel$/i })) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) - it('does not show the raw copy action while markdown stays in rendered mode', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'README.md', - type: 'markdown', - content: '# Hello', - language: '', - encoding: 'utf8', - }) + it('honors discard confirmation when leaving create mode with a dirty draft', async () => { + vi.mocked(window.confirm).mockReturnValue(false) renderWithClient( - + , ) - await waitFor(() => { - expect(screen.getByTestId('markdown-renderer')).toBeInTheDocument() - }) + fireEvent.click(screen.getByRole('button', { name: /new file/i })) + fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'docs/draft.md' } }) + fireEvent.click(screen.getByRole('button', { name: /back to viewer/i })) - expect(screen.queryByRole('button', { name: /copy raw file/i })).not.toBeInTheDocument() + expect(screen.getByLabelText(/new file path/i)).toBeInTheDocument() }) it('shows custom placeholder when filePath empty and placeholder prop provided', () => { renderWithClient( - + , ) expect(screen.getByText('No README found in this repository.')).toBeInTheDocument() }) it('relative link in MarkdownRenderer calls onNavigate', async () => { - vi.mocked(api.getFile).mockResolvedValue({ - path: 'README.md', - type: 'markdown', - content: '# Hello', - language: '', - encoding: 'utf8', - }) + vi.mocked(api.getFile).mockResolvedValue(makeTextFile({ type: 'markdown', language: '', content: '# Hello' })) const onNavigate = vi.fn() renderWithClient( - + , ) await waitFor(() => { @@ -256,13 +568,4 @@ describe('ContentPanel', () => { expect(onNavigate).toHaveBeenCalledWith('docs/guide.md') }) - - it('shows a folder placeholder without requesting file content', () => { - renderWithClient( - - ) - - expect(screen.getByText(/browse files inside/i)).toHaveTextContent('docs') - expect(api.getFile).not.toHaveBeenCalled() - }) }) diff --git a/ui/src/components/ContentPanel/ContentPanel.tsx b/ui/src/components/ContentPanel/ContentPanel.tsx index e46051e..7132d59 100644 --- a/ui/src/components/ContentPanel/ContentPanel.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.tsx @@ -1,47 +1,229 @@ -import React, { Suspense, lazy, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { Suspense, lazy, useEffect, useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../../services/api' +import type { ManualFileOperationResult } from '../../types' import CopyButton from './CopyButton' +import DeleteFileDialog from './DeleteFileDialog' +import InlineFileEditor from './InlineFileEditor' +import NewFileDraft from './NewFileDraft' const MarkdownRenderer = lazy(() => import('./MarkdownRenderer')) const CodeViewer = lazy(() => import('./CodeViewer')) +type PanelMode = 'view' | 'edit' | 'create' | 'confirm-delete' + +interface FileMutationEvent { + result: ManualFileOperationResult + nextPath: string + nextPathType: 'file' | 'dir' | 'none' +} interface Props { + canMutateFiles: boolean + refreshToken: number selectedPath: string selectedPathType: 'file' | 'dir' | 'none' branch: string onNavigate: (path: string) => void + onDirtyChange?: (value: boolean) => void + onMutationComplete?: (event: FileMutationEvent) => void placeholder?: string raw?: boolean onRawChange?: (value: boolean) => void + onStatusMessage?: (message: string) => void +} + +function parentPathOf(path: string): string { + const boundary = path.lastIndexOf('/') + return boundary >= 0 ? path.slice(0, boundary) : '' } export default function ContentPanel({ + canMutateFiles, + refreshToken, selectedPath, selectedPathType, branch, onNavigate, + onDirtyChange, + onMutationComplete, placeholder, raw = false, onRawChange, + onStatusMessage, }: Props) { + const queryClient = useQueryClient() const [showRaw, setShowRaw] = useState(raw) + const [mode, setMode] = useState('view') + const [draftPath, setDraftPath] = useState('') + const [draftContent, setDraftContent] = useState('') + const [formError, setFormError] = useState('') + const [busy, setBusy] = useState(false) const { data, isLoading, isError } = useQuery({ - queryKey: ['file', selectedPath, branch, showRaw], + queryKey: ['file', selectedPath, branch, showRaw, refreshToken], queryFn: () => api.getFile(selectedPath, branch, showRaw), enabled: !!selectedPath && selectedPathType === 'file', }) - // Reset raw view when file changes - React.useEffect(() => { + useEffect(() => { setShowRaw(raw) }, [selectedPath, raw]) + useEffect(() => { + setMode('view') + setFormError('') + setBusy(false) + }, [branch, selectedPath, selectedPathType]) + + useEffect(() => { + if (mode !== 'edit') return + setDraftContent(data?.content ?? '') + }, [data?.content, mode]) + + const dirty = useMemo(() => { + if (mode === 'edit') return draftContent !== (data?.content ?? '') + if (mode === 'create') return draftPath.trim().length > 0 || draftContent.length > 0 + return false + }, [data?.content, draftContent, draftPath, mode]) + + useEffect(() => { + onDirtyChange?.(dirty) + }, [dirty, onDirtyChange]) + + function confirmDiscardIfNeeded(): boolean { + if (!dirty) return true + return window.confirm('Discard your unsaved file changes?') + } + + async function refreshFileQueries(): Promise { + await queryClient.invalidateQueries({ queryKey: ['file'] }) + await queryClient.invalidateQueries({ queryKey: ['sync'] }) + } + + async function handleSaveEdit(): Promise { + if (!data?.revisionToken) return + setBusy(true) + setFormError('') + try { + const result = await api.updateFile({ + path: selectedPath, + content: draftContent, + revisionToken: data.revisionToken, + }) + await refreshFileQueries() + setMode('view') + onStatusMessage?.(result.message) + onMutationComplete?.({ result, nextPath: selectedPath, nextPathType: 'file' }) + } catch (error) { + const message = error instanceof Error ? error.message : (error as { error?: string }).error ?? 'Failed to update the file.' + setFormError(message) + onStatusMessage?.(message) + } finally { + setBusy(false) + } + } + + async function handleCreateFile(): Promise { + setBusy(true) + setFormError('') + try { + const result = await api.createFile({ + path: draftPath.trim(), + content: draftContent, + }) + await refreshFileQueries() + setMode('view') + setDraftContent('') + setDraftPath('') + onStatusMessage?.(result.message) + onMutationComplete?.({ result, nextPath: result.path, nextPathType: 'file' }) + } catch (error) { + const message = error instanceof Error ? error.message : (error as { error?: string }).error ?? 'Failed to create the file.' + setFormError(message) + onStatusMessage?.(message) + } finally { + setBusy(false) + } + } + + async function handleDeleteFile(): Promise { + if (!data?.revisionToken) return + setBusy(true) + setFormError('') + try { + const result = await api.deleteFile({ + path: selectedPath, + revisionToken: data.revisionToken, + }) + await refreshFileQueries() + const nextPath = parentPathOf(selectedPath) + setMode('view') + onStatusMessage?.(result.message) + onMutationComplete?.({ result, nextPath, nextPathType: nextPath ? 'dir' : 'none' }) + } catch (error) { + const message = error instanceof Error ? error.message : (error as { error?: string }).error ?? 'Failed to delete the file.' + setFormError(message) + onStatusMessage?.(message) + } finally { + setBusy(false) + } + } + + function beginCreateMode(): void { + if (!confirmDiscardIfNeeded()) return + const initialPath = selectedPathType === 'dir' ? `${selectedPath}/` : selectedPathType === 'file' ? `${parentPathOf(selectedPath)}/` : '' + setDraftPath(initialPath.replace(/^\/+/, '')) + setDraftContent('') + setFormError('') + setMode('create') + } + + const canToggleRaw = data?.type === 'markdown' || data?.type === 'text' + const loadingFallback =
+ + if (mode === 'create') { + return ( +
+
+ +
+ { void handleCreateFile() }} + onCancel={() => { + if (!confirmDiscardIfNeeded()) return + setMode('view') + setDraftContent('') + setDraftPath('') + }} + /> +
+ ) + } + if (!selectedPath) { return (
- {placeholder ?? 'Select a file to view its contents'} +
+

{placeholder ?? 'Select a file to view its contents'}

+ {canMutateFiles && ( + + )} +
) } @@ -49,7 +231,16 @@ export default function ContentPanel({ if (selectedPathType === 'dir') { return (
- Browse files inside {selectedPath} from the navigation tree or search results. +
+

+ Browse files inside {selectedPath} from the navigation tree or search results. +

+ {canMutateFiles && ( + + )} +
) } @@ -70,16 +261,51 @@ export default function ContentPanel({ ) } - const canToggleRaw = data.type === 'markdown' || data.type === 'text' - const loadingFallback =
- return (
- {canToggleRaw && ( -
+
+ {canMutateFiles && ( + <> + + + + + )} + {canToggleRaw && mode === 'view' && ( - {showRaw && ( - data.content} - className="copy-button raw-copy-button" - label="Copy raw file" - /> - )} -
- )} + )} + {showRaw && mode === 'view' && ( + data.content} + className="copy-button raw-copy-button" + label="Copy raw file" + /> + )} +
- {data.type === 'binary' ? ( + {mode === 'edit' ? ( + { void handleSaveEdit() }} + onCancel={() => { + if (!confirmDiscardIfNeeded()) return + setDraftContent(data.content) + setMode('view') + }} + /> + ) : mode === 'confirm-delete' ? ( + { void handleDeleteFile() }} + onCancel={() => { + setFormError('') + setMode('view') + }} + /> + ) : data.type === 'binary' ? (

Binary file — preview not available.

) : data.type === 'image' ? ( void + onCancel: () => void +} + +export default function DeleteFileDialog({ path, busy = false, error, onConfirm, onCancel }: Props) { + return ( +
+
+
+

Delete file

+

{path}

+
+
+ + +
+
+

This permanently removes the file from your working tree.

+ {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/ui/src/components/ContentPanel/InlineFileEditor.tsx b/ui/src/components/ContentPanel/InlineFileEditor.tsx new file mode 100644 index 0000000..44e7cde --- /dev/null +++ b/ui/src/components/ContentPanel/InlineFileEditor.tsx @@ -0,0 +1,42 @@ +interface Props { + path: string + content: string + busy?: boolean + error?: string + onChange: (value: string) => void + onSave: () => void + onCancel: () => void +} + +export default function InlineFileEditor({ path, content, busy = false, error, onChange, onSave, onCancel }: Props) { + return ( +
+
+
+

Edit file

+

{path}

+
+
+ + +
+
+ {error && ( +

+ {error} +

+ )} +