From 4c1caf9bbef3b4d8b1fa0c32c0e62cbc81d61d65 Mon Sep 17 00:00:00 2001 From: ehud Date: Sat, 4 Apr 2026 09:18:55 -0400 Subject: [PATCH 1/4] Add spec for editor and empty repo UX --- .../checklists/requirements.md | 36 +++++++ specs/007-editor-empty-repo/spec.md | 95 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 specs/007-editor-empty-repo/checklists/requirements.md create mode 100644 specs/007-editor-empty-repo/spec.md diff --git a/specs/007-editor-empty-repo/checklists/requirements.md b/specs/007-editor-empty-repo/checklists/requirements.md new file mode 100644 index 0000000..a6cc5c8 --- /dev/null +++ b/specs/007-editor-empty-repo/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Editor Workspace and Empty Repo UX + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-04 +**Feature**: [/Users/ehudamiri/Documents/projects/gitlocal/specs/007-editor-empty-repo/spec.md](/Users/ehudamiri/Documents/projects/gitlocal/specs/007-editor-empty-repo/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: All checklist items passed. +- The spec covers two primary flows: expanding the editing workspace and improving the repository landing experience when no README is present. +- No clarifications were required because the requested behavior changes are well bounded and reasonable defaults were available. diff --git a/specs/007-editor-empty-repo/spec.md b/specs/007-editor-empty-repo/spec.md new file mode 100644 index 0000000..20a798b --- /dev/null +++ b/specs/007-editor-empty-repo/spec.md @@ -0,0 +1,95 @@ +# Feature Specification: Editor Workspace and Empty Repo UX + +**Feature Branch**: `007-editor-empty-repo` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: User description: "Improve the file edit experience so the edit window is larger and makes better use of the available space, and improve the empty repository experience for newly initialized local git repos with no README so the page feels intentional instead of broken." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Edit comfortably in-place (Priority: P1) + +As a person editing a file in GitLocal, I want the edit view to use the available page space well so I can read and modify longer files without feeling constrained by a small editing area. + +**Why this priority**: Editing files is a core workflow. When the editor appears cramped inside a large window, the product feels harder to use even when the rest of the page has ample room. + +**Independent Test**: Open an existing file in edit mode on a desktop-sized window and verify that the editing surface expands to fill most of the available content area while keeping save and cancel actions visible and usable. + +**Acceptance Scenarios**: + +1. **Given** a user opens a file for editing in a wide desktop window, **When** the edit screen is shown, **Then** the editable area occupies most of the main content region rather than appearing as a small box surrounded by unused whitespace. +2. **Given** a user is editing a long file, **When** they scroll within the editor, **Then** they can view and edit a large amount of content at once without the page layout making the experience feel cramped. +3. **Given** a user enters edit mode from a file page, **When** the layout expands, **Then** the file title and primary actions remain easy to find and do not overlap or disappear. + +--- + +### User Story 2 - Land gracefully in an empty repository (Priority: P2) + +As a person opening a newly initialized repository with little or no content, I want the initial view to clearly explain the repository state and suggest useful next actions so the interface feels intentional and welcoming. + +**Why this priority**: First impressions matter. A sparse repository without a README is a common early-project state, and a blank-looking page can make the product seem broken or incomplete. + +**Independent Test**: Open a local repository that has been initialized but has no README file and verify that the primary content area shows a purposeful empty-state message with guidance instead of a visually awkward blank screen. + +**Acceptance Scenarios**: + +1. **Given** a user opens a repository that has no README file, **When** the default repository view loads, **Then** the page explains that no README is available and presents the repository as empty or newly initialized rather than broken. +2. **Given** a user opens a repository that contains files but still has no README file, **When** the default view loads, **Then** the empty-state message distinguishes the missing README from an entirely empty repository and still guides the user toward browsing files. +3. **Given** a user switches from one repository to another and the saved file context no longer applies, **When** GitLocal resets context, **Then** the transition message and the main empty state work together without making the screen look cluttered or confusing. + +--- + +### User Story 3 - Recover quickly from the default landing state (Priority: P3) + +As a person exploring a repository from its initial landing page, I want the next available actions to be obvious so I can move into browsing, creating, or opening content without guessing what to do next. + +**Why this priority**: A better empty state is most useful when it reduces hesitation and helps users continue their task immediately. + +**Independent Test**: Open a repository in the default landing state and confirm that a user can identify at least one sensible next action within a few seconds without reading technical instructions. + +**Acceptance Scenarios**: + +1. **Given** a user lands in a repository state with no primary document to display, **When** they look at the main content area, **Then** the interface highlights sensible next steps that match the repository state. +2. **Given** a user is unfamiliar with GitLocal, **When** they see the empty repository state, **Then** they can infer whether they should browse files, create content, or return to a parent folder. + +### Edge Cases + +- What happens when the user opens a repository that has no commits yet and therefore little metadata to display? +- What happens when a repository contains a README in a non-default location or casing that is not selected as the landing file? +- How does the system behave when the editor is opened on a very small window so the layout cannot expand to the same degree as on desktop? +- How does the empty state behave when a context-reset banner is shown at the same time? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST present file editing in a layout that uses the majority of the main content area available for the current window size. +- **FR-002**: The system MUST size the editable region so that users can work with long file contents without excessive unused whitespace around the editor. +- **FR-003**: The system MUST keep primary editing actions visible and understandable while the edit workspace is expanded. +- **FR-004**: The system MUST provide a deliberate empty-state experience when the repository landing view cannot show a README file. +- **FR-005**: The system MUST explain whether the current repository appears newly initialized, lacks a README, or has had its saved file context reset. +- **FR-006**: The system MUST present at least one clear next step from the empty repository state, such as browsing files, creating a first file, or moving to a parent folder. +- **FR-007**: The system MUST avoid layouts in the empty repository state that visually resemble a broken or unfinished screen. +- **FR-008**: The system MUST preserve the user’s ability to navigate the repository while showing the empty-state guidance. + +### Key Entities *(include if feature involves data)* + +- **Edit Workspace**: The file editing view, including the editable content region, file context, and primary actions needed to complete or cancel an edit. +- **Repository Landing State**: The default content shown when a repository is opened and no primary document is available for display. +- **Empty Repository Guidance**: The explanatory content and next-step actions shown when the landing state has no README or other default file to render. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In a standard desktop window, users can see substantially more file content in edit mode than before, with the editing region occupying most of the available main content space. +- **SC-002**: In usability checks, at least 90% of users can identify how to proceed from a newly initialized repository state within 10 seconds. +- **SC-003**: During manual review of newly initialized repositories, reviewers consistently describe the landing screen as intentional and understandable rather than broken or empty. +- **SC-004**: Support or feedback reports specifically calling out the cramped editor or confusing no-README landing state decrease after the feature is released. + +## Assumptions + +- Most editing sessions happen in desktop or laptop windows where there is enough horizontal space for the editor to expand noticeably. +- The improved empty-state behavior is limited to repository browsing and editing flows; it does not introduce new repository setup workflows beyond clearer guidance. +- Users may open repositories that are valid git folders but have no commits, no README, or very little content, and GitLocal should still present a coherent landing experience. +- Existing navigation controls remain available and continue to be the primary way to move through repository content. From 6729a764422a931174af67724fa48a3cdf4df697 Mon Sep 17 00:00:00 2001 From: ehud Date: Sat, 4 Apr 2026 15:08:47 -0400 Subject: [PATCH 2/4] Bump version to 0.4.6 --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5611b6c..4c78d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.4.6 + +- Reserved the next release version for the upcoming editor workspace and empty-repository UX improvements captured in `007-editor-empty-repo`. + ## 0.4.5 - Added manual local file creation, in-place editing, and deletion flows in the repository viewer with dirty-state protection and sync-aware refresh behavior. diff --git a/package-lock.json b/package-lock.json index 50c17c4..b6061cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlocal", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlocal", - "version": "0.4.5", + "version": "0.4.6", "license": "MIT", "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/package.json b/package.json index fe0bf41..9c2f5d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlocal", - "version": "0.4.5", + "version": "0.4.6", "description": "Browse any local git repository in your browser", "keywords": [ "git", From fa00e963ae2c73b3ab1ac295885eaf2f2b95e97c Mon Sep 17 00:00:00 2001 From: ehud Date: Sat, 4 Apr 2026 18:21:01 -0400 Subject: [PATCH 3/4] Implement folder-first repo viewer polish --- AGENTS.md | 9 +- .../contracts/content-panel-navigation.md | 64 +++++ specs/007-editor-empty-repo/data-model.md | 84 +++++++ specs/007-editor-empty-repo/plan.md | 99 ++++++++ specs/007-editor-empty-repo/quickstart.md | 33 +++ specs/007-editor-empty-repo/research.md | 41 ++++ specs/007-editor-empty-repo/spec.md | 55 ++++- specs/007-editor-empty-repo/tasks.md | 223 ++++++++++++++++++ src/git/repo.ts | 56 ++++- src/handlers/git.ts | 2 + src/services/repo-watch.ts | 5 +- src/types.ts | 6 +- tests/integration/server.test.ts | 40 +++- tests/unit/git/repo.test.ts | 54 +++++ tests/unit/handlers/git.test.ts | 70 +++++- ui/src/App.css | 175 +++++++++++++- ui/src/App.test.tsx | 149 +++++++++++- ui/src/App.tsx | 72 +++++- .../ContentPanel/ContentPanel.test.tsx | 198 +++++++++++++--- .../components/ContentPanel/ContentPanel.tsx | 195 ++++++++++++--- .../ContentPanel/InlineFileEditor.tsx | 2 +- .../ContentPanel/MarkdownRenderer.tsx | 11 +- .../components/ContentPanel/NewFileDraft.tsx | 2 +- ui/src/services/api.ts | 2 +- ui/src/types/index.ts | 5 +- 25 files changed, 1551 insertions(+), 101 deletions(-) create mode 100644 specs/007-editor-empty-repo/contracts/content-panel-navigation.md create mode 100644 specs/007-editor-empty-repo/data-model.md create mode 100644 specs/007-editor-empty-repo/plan.md create mode 100644 specs/007-editor-empty-repo/quickstart.md create mode 100644 specs/007-editor-empty-repo/research.md create mode 100644 specs/007-editor-empty-repo/tasks.md diff --git a/AGENTS.md b/AGENTS.md index b1d7b32..34b5c79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ Auto-generated from all feature plans. Last updated: 2026-04-04 - 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) +- 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, react-markdown, remark-gfm, rehype-highlight, Vite 7, Vitest, React Testing Library, esbuild (007-editor-empty-repo) +- TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vitest, React Testing Library (007-editor-empty-repo) +- None; runtime state is derived from local filesystem metadata, git metadata, browser URL state, and in-memory server/UI state (007-editor-empty-repo) ## Project Structure @@ -41,9 +44,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 +- 007-editor-empty-repo: Added TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vitest, React Testing Library +- 007-editor-empty-repo: 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, react-markdown, remark-gfm, rehype-highlight, Vite 7, Vitest, React Testing Library, esbuild +- 007-editor-empty-repo: 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 diff --git a/specs/007-editor-empty-repo/contracts/content-panel-navigation.md b/specs/007-editor-empty-repo/contracts/content-panel-navigation.md new file mode 100644 index 0000000..9001dbf --- /dev/null +++ b/specs/007-editor-empty-repo/contracts/content-panel-navigation.md @@ -0,0 +1,64 @@ +# Content Panel Navigation Contract + +## Repository Info Contract + +- `GET /api/info` continues to return the current repository identity and viewer mode payload. +- The payload includes lightweight repository-state metadata: + - `hasCommits`: whether the opened repository has at least one reachable commit. + - `rootEntryCount`: number of immediate repository-root entries available for browsing. +- Success expectations: + - Freshly initialized repositories with no commits return `hasCommits: false`. + - Repositories with browseable root content return `rootEntryCount > 0` even when no `README` is present. + +## README Discovery Contract + +- `GET /api/readme` continues to return `{ path: string }`. +- An empty `path` means no default `README` was found for the repository. +- The absence of a `README` must not force a dedicated custom recovery panel in the main content area. + +## Main Panel Folder View Contract + +- When no file is selected, the main content area renders the current folder's immediate child files and folders. +- When the selected path is a folder, the main content area renders that folder using the same folder-list presentation model. +- The folder list must: + - List immediate children only. + - Provide an explicit `Open` action on the right side of each row. + - Support opening a row item by double-clicking it. + - Reuse a similar visual style and interaction feel to the existing non-git folder browser. + - Show an intentional empty-folder message when there are no visible child entries. +- Recursive expansion remains the responsibility of the left tree; the main panel stays single-level for the active folder. + +## Main Panel Recovery Behavior Contract + +- The main content area must not render the prior "Pick up where you left off" recovery message or an equivalent dedicated recovery panel. +- If repository-switch logic clears a stale file selection, the main panel falls back to the current folder list instead of a custom recovery state. +- Any existing status or reset banner outside the main panel may remain, but it must not block or replace the folder list. + +## Edit Workspace Layout Contract + +- Entering edit mode keeps the user in the main content panel. +- The edit workspace must: + - Use most of the panel width available in desktop layouts. + - Present the textarea at a substantially larger default height than the previous implementation. + - Keep cancel and save controls visible and understandable. +- The responsive layout may stack controls differently on smaller screens, but it must not regress to the previous cramped presentation at common desktop widths. + +## Rendered Markdown Contract + +- Rendered markdown presentation must not display markdown comments or other hidden comment-style content. +- Raw mode continues to show the original markdown source unchanged. +- Comment suppression applies only to rendered presentation, not to file storage or raw viewing. + +## Test Coverage Contract + +- UI tests must cover: + - Expanded editor presentation and visible action controls. + - Main-panel folder list as the default fallback when no file is selected. + - Folder list row behavior for explicit `Open` actions and double-click navigation. + - Visual/structural consistency expectations with the existing non-git folder browser. + - Empty-folder messaging in the main content area. + - Hidden-comment suppression in rendered markdown while raw mode remains unchanged. +- Backend tests must cover: + - Repository info metadata for repos with and without commits. + - Root entry counting for repositories with browseable content. + - Directory-list behavior needed by the main-panel folder view. diff --git a/specs/007-editor-empty-repo/data-model.md b/specs/007-editor-empty-repo/data-model.md new file mode 100644 index 0000000..e8cce7f --- /dev/null +++ b/specs/007-editor-empty-repo/data-model.md @@ -0,0 +1,84 @@ +# Data Model: Editor Workspace, Folder-First Main View, and Markdown Comment Hiding + +## Edit Workspace State + +- **Description**: The UI state that controls how the content panel presents an editable file and how much of the viewport is allocated to the editing surface. +- **Fields**: + - `mode`: `view`, `edit`, `create`, or `confirm-delete`. + - `selectedPath`: Repository-relative file path currently shown in the panel. + - `canMutateFiles`: Whether file-editing actions are allowed in the current repository context. + - `busy`: Whether a save, create, or delete action is in progress. + - `error`: User-displayable error copy for the current editing session. +- **Validation rules**: + - Expanded editor layout applies only when `mode` is `edit` or `create`. + - Primary save and cancel actions remain visible in supported desktop widths. +- **Relationships**: + - Owned by the content panel UI. + - Uses existing file read and mutation flows. + +## Repository Summary + +- **Description**: Lightweight server-provided metadata used to describe the currently opened repository and seed folder-first navigation behavior. +- **Fields**: + - `name`: Repository display name. + - `path`: Opened repository path. + - `currentBranch`: Current working-tree branch name, if any. + - `isGitRepo`: Whether the opened folder is a valid git repository. + - `pickerMode`: Whether the app is currently in folder-picker mode. + - `version`: Running application version. + - `hasCommits`: Whether the repository has at least one reachable commit. + - `rootEntryCount`: Count of immediate browseable entries at the repository root. +- **Validation rules**: + - `rootEntryCount` excludes `.git` internals and any path outside the repository boundary. + - `hasCommits` remains false for a freshly initialized repository with no commits. +- **Relationships**: + - Returned by the repository info contract. + - Combined with current folder data to determine sensible default main-panel behavior. + +## Main Panel Directory View State + +- **Description**: The normalized content-panel state shown when no file is selected or when the selected path is a folder. +- **Fields**: + - `folderPath`: Repository-relative folder path currently represented in the main panel. + - `entries`: Immediate child files and folders visible inside that folder. + - `empty`: Whether the folder has no visible child entries. + - `origin`: `default-folder-view` or `selected-folder-view`. + - `supportsDoubleClick`: Whether row double-click opens the underlying item. + - `rowActionLabel`: Visible action text shown on each row, expected to be `Open`. +- **Validation rules**: + - `entries` include only immediate children, not recursive descendants. + - Hidden repository internals must not appear. + - Each entry exposes both row-level double-click behavior and an explicit action button. + - Empty folders render an intentional empty state instead of an error presentation. +- **Relationships**: + - Derived from tree or directory-list data for the current folder context. + - Rendered by the content panel when the main view should show browseable items instead of a file. + +## Directory Entry + +- **Description**: A single browseable item shown in the main-panel folder list. +- **Fields**: + - `path`: Repository-relative path for the item. + - `name`: Display name shown in the row. + - `type`: `file` or `dir`. + - `openTarget`: The path that should be opened when the row action or double-click is used. +- **Validation rules**: + - `name` is the final path segment of `path`. + - `openTarget` must resolve within the current repository boundary. +- **Relationships**: + - Produced from server tree/directory data. + - Consumed by the content panel folder list and its row interactions. + +## Rendered Markdown View + +- **Description**: The presentation-only version of markdown content shown when the user chooses rendered mode instead of raw mode. +- **Fields**: + - `sourceContent`: Original markdown file text. + - `renderContent`: Sanitized markdown text or AST used for rendered presentation. + - `rawVisible`: Whether the viewer is currently showing raw source instead of rendered markdown. +- **Validation rules**: + - Markdown comments intended to stay hidden must not appear in `renderContent`. + - Raw mode continues to show the original source unchanged. +- **Relationships**: + - Produced by the markdown rendering path. + - Used only for rendered markdown presentation. diff --git a/specs/007-editor-empty-repo/plan.md b/specs/007-editor-empty-repo/plan.md new file mode 100644 index 0000000..e1d64dc --- /dev/null +++ b/specs/007-editor-empty-repo/plan.md @@ -0,0 +1,99 @@ +# Implementation Plan: Editor Workspace, Folder-First Main View, and Markdown Comment Hiding + +**Branch**: `007-editor-empty-repo` | **Date**: 2026-04-04 | **Spec**: [/Users/ehudamiri/Documents/projects/gitlocal/specs/007-editor-empty-repo/spec.md](/Users/ehudamiri/Documents/projects/gitlocal/specs/007-editor-empty-repo/spec.md) +**Input**: Feature specification from `/Users/ehudamiri/Documents/projects/gitlocal/specs/007-editor-empty-repo/spec.md` + +## Summary + +Make the inline editor use the main content area more fully, hide markdown comments in rendered mode, and replace the current no-selection / folder-error experience with a folder-first content view in the main panel. When no primary file is selected, GitLocal should show the current folder's files and subfolders using a visual pattern consistent with the existing non-git folder browser, with an `Open` action on each row and double-click navigation. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 22+ +**Primary Dependencies**: Hono, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vitest, React Testing Library +**Storage**: None; runtime state is derived from local filesystem metadata, git metadata, browser URL state, and in-memory server/UI state +**Testing**: Vitest, React Testing Library, integration tests via existing server test suite +**Target Platform**: Local desktop browser with Node-based local server +**Project Type**: Full-stack web application +**Performance Goals**: Preserve current interactive browsing responsiveness for repository and folder navigation +**Constraints**: Offline-capable, no database, GitHub-like browsing experience, >=90% per-file branch coverage +**Scale/Scope**: Single local repository or folder session with content-panel browsing, editing, and navigation + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- `Code quality`: Pass. The change fits the existing Hono + React codebase and can be implemented with typed server/client contracts plus focused tests. +- `Test-first verification`: Pass. The work can be covered with backend metadata/directory tests and UI tests for editor expansion, folder list behavior, and markdown rendering behavior. +- `Local-first product behavior`: Pass. All required signals come from local filesystem and git metadata already available to the app. +- `Simplicity`: Pass. The plan extends the current content panel instead of introducing a new navigation surface or separate editor shell. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-editor-empty-repo/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── content-panel-navigation.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── handlers/ +│ └── git.ts +├── git/ +│ ├── repo.ts +│ └── tree.ts +└── types.ts + +tests/ +├── integration/ +│ └── server.test.ts +└── unit/ + ├── git/ + │ └── repo.test.ts + └── handlers/ + └── git.test.ts + +ui/ +└── src/ + ├── App.tsx + ├── App.css + ├── App.test.tsx + ├── services/ + │ └── api.ts + ├── types/ + │ └── index.ts + └── components/ + └── ContentPanel/ + ├── ContentPanel.tsx + ├── ContentPanel.test.tsx + ├── InlineFileEditor.tsx + └── MarkdownRenderer.tsx +``` + +**Structure Decision**: Keep the existing single server + single UI structure. Server work will extend repository metadata and folder listing support, while UI work will concentrate in the content panel, shared API/types, and styling/tests. + +## Phase 0: Research Focus + +- Confirm the minimum server metadata needed to choose between a selected file view and a folder-first main view. +- Confirm how to reuse the existing non-git folder-browser look and interaction model inside the repository content panel without duplicating logic unnecessarily. +- Confirm the safest rendered-markdown path for hiding comments only in rendered mode. + +## Phase 1: Design Focus + +- Define the normalized main-panel state when no file is selected, including current-folder list presentation and empty-folder messaging. +- Define the server/client contract for directory entries, including enough information for per-row `Open` actions and double-click behavior. +- Define responsive layout expectations for the larger inline editor so desktop space is used better without breaking smaller viewports. + +## Complexity Tracking + +No constitution violations are expected for this feature. diff --git a/specs/007-editor-empty-repo/quickstart.md b/specs/007-editor-empty-repo/quickstart.md new file mode 100644 index 0000000..8537913 --- /dev/null +++ b/specs/007-editor-empty-repo/quickstart.md @@ -0,0 +1,33 @@ +# Quickstart: Editor Workspace, Folder-First Main View, and Markdown Comment Hiding + +## Prerequisites + +- Install dependencies with `npm ci` and `npm --prefix ui ci`. +- Start the UI and server locally with `npm run dev:ui` and `npm run dev:server`. +- Prepare three local repositories or folders for manual validation: + - A normal repository with a `README.md` that can be edited. + - A freshly initialized repository created with `git init` and no `README.md`. + - A repository with at least one nested folder and markdown content that contains hidden comment lines or comment blocks. + +## Validation Flow + +1. Open GitLocal against the repository that contains `README.md`. +2. Select `README.md`, enter edit mode, and confirm the editor uses most of the main content area instead of appearing inside a cramped centered card. +3. Verify the save and cancel controls remain visible and usable while the expanded editor is open. +4. Resize the browser narrower and confirm the edit workspace remains usable without overlapping controls. +5. Open a freshly initialized repository with no `README.md`. +6. Confirm the main content area shows the current folder contents rather than a sparse blank screen or a dedicated recovery message. +7. Verify each listed row exposes an `Open` action on the right and that double-clicking a row opens the same item. +8. Compare the in-repo folder list with the existing non-git folder browser and confirm the look and feel is intentionally similar. +9. Switch between repositories so the saved file selection is cleared and confirm the main content area still falls back to the current folder list instead of the prior "Pick up where you left off" experience. +10. Select a folder from the tree and confirm the main content area shows that folder's immediate child files and folders. +11. Use the row button to open one child item, then return and open another item by double-clicking it. +12. Open an empty folder and confirm the content area shows an intentional empty-folder message instead of an error-like state. +13. Open a markdown file that contains hidden comments and confirm rendered markdown does not show the commented content. +14. Switch the same markdown file to raw view and confirm the source still shows the original comment text. + +## Automated Checks + +- Run `npm test`. +- Run `npm run lint`. +- Run `npm run build`. diff --git a/specs/007-editor-empty-repo/research.md b/specs/007-editor-empty-repo/research.md new file mode 100644 index 0000000..1fd778b --- /dev/null +++ b/specs/007-editor-empty-repo/research.md @@ -0,0 +1,41 @@ +# Research: Editor Workspace, Folder-First Main View, and Markdown Comment Hiding + +## Decision 1: Expand the inline editor inside the current content panel instead of introducing a new editing surface + +- **Decision**: Keep editing inside the existing content panel and make the editor container and textarea stretch to use substantially more of the available panel width and height. +- **Rationale**: The current edit flow already handles file mutation, action controls, and data refresh. The problem is presentation density, not workflow coverage, so improving the existing surface is the lowest-risk change. +- **Alternatives considered**: + - Full-screen or modal editor: rejected because it adds navigation overhead and breaks the lightweight browsing flow. + - New multi-pane IDE layout: rejected because it expands scope beyond the product's current design direction. + +## Decision 2: Use the current folder as the default main-panel fallback instead of a custom recovery or empty-state panel + +- **Decision**: When no primary file is selected, render the current folder's immediate child files and folders in the main panel rather than a dedicated "pick up where you left off" or repository-landing message. +- **Rationale**: The user wants the main view to remain actionable and consistent with browsing behavior. Showing the current folder contents gives immediate utility and avoids making the panel feel like a dead end. +- **Alternatives considered**: + - Dedicated recovery panel after repo changes: rejected because the clarified requirement explicitly removes that experience. + - Generic no-selection placeholder: rejected because it provides less value than showing actual repository contents. + +## Decision 3: Reuse the non-git folder browser's visual and interaction pattern inside repository browsing + +- **Decision**: The in-repo folder list should adopt the same basic row layout and interaction style as the existing non-git folder browser, including a visible action affordance on the right and double-click support on the row. +- **Rationale**: Reusing an already-familiar pattern keeps the product consistent and reduces the amount of new UI users need to learn. +- **Alternatives considered**: + - Introduce a brand-new folder-view design for repositories: rejected because it would create unnecessary visual inconsistency. + - Rely on double-click alone: rejected because the user explicitly asked for an `Open` button as well. + +## Decision 4: Keep lightweight repository metadata so the client can choose a sensible default folder context + +- **Decision**: Continue exposing enough repository metadata for the client to know whether the repo has commits and whether the root has browseable entries, while using current-folder navigation data for the main-panel list itself. +- **Rationale**: Even after removing the custom recovery panel, the client still needs authoritative local signals to decide how to seed the initial browsing view and avoid awkward empty states in freshly initialized repositories. +- **Alternatives considered**: + - Infer all fallback behavior from ad hoc tree calls only: rejected because it makes the no-selection experience harder to reason about. + - Drop repo metadata entirely: rejected because fresh `git init` repositories still need to be distinguished from populated repos. + +## Decision 5: Hide markdown comments only in rendered mode + +- **Decision**: Strip or suppress markdown comment content only for rendered markdown presentation, while leaving raw mode unchanged. +- **Rationale**: Users expect rendered markdown to hide commented content, but raw mode should remain a faithful view of the file source. +- **Alternatives considered**: + - Show comments in rendered mode: rejected because it directly conflicts with the clarified requirement. + - Strip comments from both rendered and raw views: rejected because it would make raw mode inaccurate. diff --git a/specs/007-editor-empty-repo/spec.md b/specs/007-editor-empty-repo/spec.md index 20a798b..42274b6 100644 --- a/specs/007-editor-empty-repo/spec.md +++ b/specs/007-editor-empty-repo/spec.md @@ -5,6 +5,15 @@ **Status**: Draft **Input**: User description: "Improve the file edit experience so the edit window is larger and makes better use of the available space, and improve the empty repository experience for newly initialized local git repos with no README so the page feels intentional instead of broken." +## Clarifications + +### Session 2026-04-04 + +- Q: When a user opens a folder, should GitLocal show an in-panel directory view with buttons, double-click navigation, or one interaction only? → A: Show an in-panel directory view with both explicit action buttons and double-click support. +- Q: Should GitLocal show the "Pick up where you left off" recovery message in the main view after repository context resets? → A: No, remove that message from the main view. +- Q: What should the main content area show by default while browsing repository folders? → A: Show a list view of the current folder's files and subfolders in the main view. +- Q: What visual style should the in-repo folder list use? → A: Use a similar look and feel to the existing non-git folder browser. + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - Edit comfortably in-place (Priority: P1) @@ -35,22 +44,40 @@ As a person opening a newly initialized repository with little or no content, I 1. **Given** a user opens a repository that has no README file, **When** the default repository view loads, **Then** the page explains that no README is available and presents the repository as empty or newly initialized rather than broken. 2. **Given** a user opens a repository that contains files but still has no README file, **When** the default view loads, **Then** the empty-state message distinguishes the missing README from an entirely empty repository and still guides the user toward browsing files. -3. **Given** a user switches from one repository to another and the saved file context no longer applies, **When** GitLocal resets context, **Then** the transition message and the main empty state work together without making the screen look cluttered or confusing. +3. **Given** a user switches from one repository to another and the saved file context no longer applies, **When** GitLocal resets context, **Then** the main view falls back to the current folder list without showing a custom recovery message in the content area. --- ### User Story 3 - Recover quickly from the default landing state (Priority: P3) -As a person exploring a repository from its initial landing page, I want the next available actions to be obvious so I can move into browsing, creating, or opening content without guessing what to do next. +As a person exploring a repository from its default landing state, I want the main view to immediately show the current folder contents so I can continue browsing without extra explanation screens. + +**Why this priority**: The fastest way to recover from a default or reset state is to put useful repository content in front of the user immediately, instead of asking them to interpret a special recovery message. + +**Independent Test**: Open a repository in the default landing state and confirm that the main view shows the current folder contents in a list layout that allows the user to open files or folders immediately. + +**Acceptance Scenarios**: + +1. **Given** a user lands in a repository state with no primary document to display, **When** they look at the main content area, **Then** they see the current folder's files and folders in a list they can act on immediately. +2. **Given** a user is unfamiliar with GitLocal, **When** they see the folder list in the main view, **Then** they can understand how to continue browsing without a separate explanatory recovery panel. + +--- + +### User Story 4 - Browse folders in the content area (Priority: P2) + +As a person opening a folder in GitLocal, I want the main content area to show the folder contents and let me open items from there so the app feels complete instead of broken when no file is selected. -**Why this priority**: A better empty state is most useful when it reduces hesitation and helps users continue their task immediately. +**Why this priority**: Folder selection is a basic browsing action. Showing an error or blank state for folders breaks the core repository-navigation experience and makes the product feel unreliable. -**Independent Test**: Open a repository in the default landing state and confirm that a user can identify at least one sensible next action within a few seconds without reading technical instructions. +**Independent Test**: Open a folder from the tree and verify the content panel lists child files and folders in the same general style as the non-git folder browser, allows activation with a button, and supports double-click navigation. **Acceptance Scenarios**: -1. **Given** a user lands in a repository state with no primary document to display, **When** they look at the main content area, **Then** the interface highlights sensible next steps that match the repository state. -2. **Given** a user is unfamiliar with GitLocal, **When** they see the empty repository state, **Then** they can infer whether they should browse files, create content, or return to a parent folder. +1. **Given** a user selects a folder in the repository tree, **When** the folder view opens, **Then** the main content area lists the folder's immediate files and folders in a list view instead of showing an error state. +2. **Given** a user views a folder in the content area, **When** they click the provided action button for a child item, **Then** GitLocal opens that file or folder. +3. **Given** a user views a folder in the content area, **When** they double-click a child item, **Then** GitLocal opens that file or folder without requiring the action button. +4. **Given** a folder view is shown in the main content area, **When** the user compares it to the existing non-git folder browser, **Then** the row layout, action placement, and overall presentation feel visually consistent. +5. **Given** a folder has no visible children, **When** its folder view opens, **Then** the content area shows an intentional empty-folder message rather than a broken or generic error state. ### Edge Cases @@ -58,6 +85,8 @@ As a person exploring a repository from its initial landing page, I want the nex - What happens when a repository contains a README in a non-default location or casing that is not selected as the landing file? - How does the system behave when the editor is opened on a very small window so the layout cannot expand to the same degree as on desktop? - How does the empty state behave when a context-reset banner is shown at the same time? +- How does the content panel behave when a folder contains many entries or a mix of files and folders? +- What happens when markdown source includes commented lines or hidden comment blocks that should not be shown in rendered output? ## Requirements *(mandatory)* @@ -71,12 +100,21 @@ As a person exploring a repository from its initial landing page, I want the nex - **FR-006**: The system MUST present at least one clear next step from the empty repository state, such as browsing files, creating a first file, or moving to a parent folder. - **FR-007**: The system MUST avoid layouts in the empty repository state that visually resemble a broken or unfinished screen. - **FR-008**: The system MUST preserve the user’s ability to navigate the repository while showing the empty-state guidance. +- **FR-009**: The system MUST suppress markdown comments or commented source lines that are intended to stay hidden from rendered markdown output. +- **FR-010**: The system MUST use the current folder list as the default main-view fallback when no primary file is selected. +- **FR-011**: The system MUST NOT show the "Pick up where you left off" recovery message or an equivalent custom recovery panel in the main content area. +- **FR-012**: The system MUST render a directory list view in the main content area when the selected path is a folder. +- **FR-013**: The system MUST list the selected folder's immediate child files and folders in that directory view. +- **FR-014**: The system MUST let users open listed folder items through both an explicit open button and a double-click interaction. +- **FR-015**: The system MUST style the in-repository directory list view similarly to the existing non-git folder browser in the app. +- **FR-016**: The system MUST show an intentional empty-folder state when a selected folder has no visible child items. ### Key Entities *(include if feature involves data)* - **Edit Workspace**: The file editing view, including the editable content region, file context, and primary actions needed to complete or cancel an edit. - **Repository Landing State**: The default content shown when a repository is opened and no primary document is available for display. - **Empty Repository Guidance**: The explanatory content and next-step actions shown when the landing state has no README or other default file to render. +- **Directory View**: The content-panel presentation of a selected folder, including its visible child files/folders and the interactions used to open them. ## Success Criteria *(mandatory)* @@ -86,6 +124,9 @@ As a person exploring a repository from its initial landing page, I want the nex - **SC-002**: In usability checks, at least 90% of users can identify how to proceed from a newly initialized repository state within 10 seconds. - **SC-003**: During manual review of newly initialized repositories, reviewers consistently describe the landing screen as intentional and understandable rather than broken or empty. - **SC-004**: Support or feedback reports specifically calling out the cramped editor or confusing no-README landing state decrease after the feature is released. +- **SC-005**: In manual validation, 100% of tested folder selections open a usable in-panel directory view instead of a broken or error-like presentation. +- **SC-006**: In manual validation, markdown files containing hidden comments no longer display those comments in rendered markdown output. +- **SC-007**: In manual validation, the main-view directory list feels visually consistent with the existing non-git folder browser and exposes a visible open action on every listed row. ## Assumptions @@ -93,3 +134,5 @@ As a person exploring a repository from its initial landing page, I want the nex - The improved empty-state behavior is limited to repository browsing and editing flows; it does not introduce new repository setup workflows beyond clearer guidance. - Users may open repositories that are valid git folders but have no commits, no README, or very little content, and GitLocal should still present a coherent landing experience. - Existing navigation controls remain available and continue to be the primary way to move through repository content. +- Folder browsing remains repository-scoped and should expose only the selected folder's immediate child items in the content-area directory view. +- When no file is selected, showing the current folder's contents is preferable to showing a dedicated recovery message in the main content area. diff --git a/specs/007-editor-empty-repo/tasks.md b/specs/007-editor-empty-repo/tasks.md new file mode 100644 index 0000000..cda2c4c --- /dev/null +++ b/specs/007-editor-empty-repo/tasks.md @@ -0,0 +1,223 @@ +# Tasks: Editor Workspace, Folder-First Main View, and Markdown Comment Hiding + +**Input**: Design documents from `specs/007-editor-empty-repo/` +**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 plan, contracts, quickstart, and project constitution require coverage-preserving validation for repository metadata, folder-list behavior, editor layout, and rendered markdown behavior. + +**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 belongs to (e.g. `US1`, `US2`) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare shared client/server types and feature documentation touchpoints for the refreshed content-panel behavior. + +- [X] T001 Add the refreshed folder-first content-panel state shapes to `src/types.ts` +- [X] T002 [P] Mirror the refreshed content-panel and directory-entry state shapes in `ui/src/types/index.ts` +- [X] T003 [P] Update repository info and folder-view API typing in `ui/src/services/api.ts` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the shared repository metadata and directory-view plumbing that all user stories depend on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Extend repository metadata helpers for commit detection, root-entry counting, and folder-first defaults in `src/git/repo.ts` +- [X] T005 Add or refine directory-list retrieval for immediate child entries in `src/git/tree.ts` +- [X] T006 Expose enriched repository metadata and directory-list responses in `src/handlers/git.ts` +- [X] T007 Wire repository metadata and directory-list data through the viewer state flow in `ui/src/App.tsx` +- [X] T008 Create a reusable main-panel directory-list rendering path in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T009 [P] Add backend coverage for repository metadata and directory-list behavior in `tests/unit/git/repo.test.ts` +- [X] T010 [P] Add handler coverage for repository info, README lookup, and folder-list responses in `tests/unit/handlers/git.test.ts` +- [X] T011 [P] Add integration coverage for repository metadata and folder-list contracts in `tests/integration/server.test.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Edit comfortably in-place (Priority: P1) 🎯 MVP + +**Goal**: Make the inline editor use the available content area much more effectively while keeping controls visible and understandable. + +**Independent Test**: Open an existing file in edit mode on a desktop-sized window and verify that the editing surface expands to fill most of the available content area while keeping save and cancel actions visible and usable. + +### Implementation for User Story 1 + +- [X] T012 [P] [US1] Restructure the inline editor header and editing surface for a wider, taller layout in `ui/src/components/ContentPanel/InlineFileEditor.tsx` +- [X] T013 [US1] Update content-panel edit-mode composition to support the expanded editor workspace in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T014 [P] [US1] Adjust editor, toolbar, and content-panel sizing rules for desktop-first expansion in `ui/src/App.css` +- [X] T015 [P] [US1] Add frontend coverage for expanded edit layout, action visibility, and responsive fallback behavior in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 1 should be independently functional and testable + +--- + +## Phase 4: User Story 2 - Land gracefully in an empty repository (Priority: P2) + +**Goal**: Make no-README repositories feel intentional and understandable while distinguishing freshly initialized repositories from populated repositories that simply lack a README. + +**Independent Test**: Open a local repository that has been initialized but has no README file and verify that the primary content area explains the repository state clearly instead of looking broken or unfinished. + +### Implementation for User Story 2 + +- [X] T016 [US2] Implement no-README and freshly initialized repository state classification in `ui/src/App.tsx` +- [X] T017 [US2] Update the content-panel empty-repository and missing-README messaging around the folder-first main view in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T018 [P] [US2] Refine empty-repository and missing-README presentation styles in `ui/src/App.css` +- [X] T019 [P] [US2] Add app-level coverage for empty-repository versus missing-README classification in `ui/src/App.test.tsx` +- [X] T020 [P] [US2] Add content-panel coverage for intentional no-README and empty-repository messaging in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 2 should be independently functional and testable + +--- + +## Phase 5: User Story 4 - Browse folders in the content area (Priority: P2) + +**Goal**: Let users browse selected folders directly in the main content area with a familiar list presentation and clear open actions. + +**Independent Test**: Open a folder from the tree and verify the content panel lists child files and folders in the same general style as the non-git folder browser, allows activation with an `Open` button, and supports double-click navigation. + +### Implementation for User Story 4 + +- [X] T021 [US4] Implement selected-folder content rendering with per-row open actions in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T022 [P] [US4] Apply non-git-browser-inspired row layout and empty-folder styles in `ui/src/App.css` +- [X] T023 [P] [US4] Add content-panel coverage for selected-folder list rendering, open buttons, double-click navigation, and empty-folder messaging in `ui/src/components/ContentPanel/ContentPanel.test.tsx` +- [X] T024 [P] [US4] Add app-level coverage for opening files and folders from the main-panel directory view in `ui/src/App.test.tsx` + +**Checkpoint**: User Story 4 should be independently functional and testable + +--- + +## Phase 6: User Story 3 - Recover quickly from the default landing state (Priority: P3) + +**Goal**: Make the default or reset state immediately useful by showing the current folder contents in the main panel instead of a custom recovery screen. + +**Independent Test**: Open a repository in the default landing state and confirm that the main view shows the current folder contents in a list layout that lets the user open files or folders immediately. + +### Implementation for User Story 3 + +- [X] T025 [US3] Replace the prior main-panel recovery experience with the current-folder fallback list in `ui/src/App.tsx` +- [X] T026 [US3] Remove the dedicated recovery-panel rendering path from `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T027 [P] [US3] Add app-level coverage for repo-switch resets falling back to the current folder list in `ui/src/App.test.tsx` +- [X] T028 [P] [US3] Add content-panel coverage confirming no custom recovery panel appears in the main view in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +**Checkpoint**: User Story 3 should be independently functional and testable + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Finish rendered-markdown behavior, verify cross-story polish, and run full validation. + +- [X] T029 Implement rendered-markdown comment suppression while preserving raw-source fidelity in `ui/src/components/ContentPanel/MarkdownRenderer.tsx` +- [X] T030 [P] Add rendered-markdown coverage for hidden comments in rendered mode and visible comments in raw mode in `ui/src/components/ContentPanel/ContentPanel.test.tsx` +- [X] T031 Review content-panel copy, row labels, and layout consistency across empty, folder, and edit states in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [ ] T032 [P] Run the validation flow captured in `specs/007-editor-empty-repo/quickstart.md` and update any follow-up notes in `specs/007-editor-empty-repo/quickstart.md` +- [X] T033 [P] Run full verification for the feature with `npm test`, `npm run lint`, and `npm run build` from `package.json` + +--- + +## 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 +- **User Story 4 (Phase 5)**: Depends on Foundational completion +- **User Story 3 (Phase 6)**: Depends on Foundational completion and benefits from the folder-list behavior delivered in User Story 4 +- **Polish (Phase 7)**: 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 - no dependency on other stories +- **User Story 4 (P2)**: Can start after Foundational - no dependency on US1 or US2 +- **User Story 3 (P3)**: Depends on the shared folder-list primitives from Foundational and is best completed after US4 so the same main-panel list behavior is reused cleanly + +### Within Each User Story + +- Shared UI wiring before story-specific styling where both touch the same state +- Main behavior before story-specific tests +- Story behavior complete before cross-cutting polish + +### Parallel Opportunities + +- `T002` and `T003` can run in parallel after `T001` +- `T009`, `T010`, and `T011` can run in parallel after `T004` through `T008` +- `T012`, `T014`, and `T015` can run in parallel within US1 after the foundational phase +- `T018`, `T019`, and `T020` can run in parallel within US2 after `T016` and `T017` +- `T022`, `T023`, and `T024` can run in parallel within US4 after `T021` +- `T027` and `T028` can run in parallel within US3 after `T025` and `T026` +- `T030`, `T032`, and `T033` can run in parallel during polish after `T029` + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Restructure the inline editor header and editing surface for a wider, taller layout in ui/src/components/ContentPanel/InlineFileEditor.tsx" +Task: "Adjust editor, toolbar, and content-panel sizing rules for desktop-first expansion in ui/src/App.css" +Task: "Add frontend coverage for expanded edit layout, action visibility, and responsive fallback behavior in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +## Parallel Example: User Story 2 + +```bash +Task: "Refine empty-repository and missing-README presentation styles in ui/src/App.css" +Task: "Add app-level coverage for empty-repository versus missing-README classification in ui/src/App.test.tsx" +Task: "Add content-panel coverage for intentional no-README and empty-repository messaging in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +## Parallel Example: User Story 4 + +```bash +Task: "Apply non-git-browser-inspired row layout and empty-folder styles in ui/src/App.css" +Task: "Add content-panel coverage for selected-folder list rendering, open buttons, double-click navigation, and empty-folder messaging in ui/src/components/ContentPanel/ContentPanel.test.tsx" +Task: "Add app-level coverage for opening files and folders from the main-panel directory view in ui/src/App.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 expanded editor experience independently before moving on + +### Incremental Delivery + +1. Complete Setup + Foundational to establish metadata and folder-list plumbing +2. Deliver User Story 1 for the editor workspace improvement as the MVP +3. Add User Story 2 for intentional empty-repository and missing-README behavior +4. Add User Story 4 for main-panel folder browsing +5. Add User Story 3 so default and reset states fall back to the folder list cleanly +6. Finish with rendered-markdown polish and full validation + +### Parallel Team Strategy + +1. One developer handles server metadata and backend tests in Phase 2 while another prepares shared UI type and rendering work from Phase 1 and `T008` +2. After Foundational completes: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 4 +3. Rejoin for User Story 3 and final polish once the shared folder list is stable + +--- + +## Notes + +- [P] tasks are safe parallel opportunities because they target different files or depend only on completed shared work +- 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 0a5e138..47cded4 100644 --- a/src/git/repo.ts +++ b/src/git/repo.ts @@ -61,18 +61,59 @@ export function getCurrentBranch(repoPath: string): string { } } +export function hasCommits(repoPath: string): boolean { + const result = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { + cwd: repoPath, + stdio: 'ignore', + }) + return result.status === 0 +} + +export function getBrowseableRootEntryCount(repoPath: string): number { + return listWorkingTreeDirectoryEntries(repoPath) + .filter((entry) => !entry.name.startsWith('.')) + .length +} + export function getInfo(repoPath: string): RepoInfo { const version = getAppVersion() if (!repoPath) { - return { name: '', path: '', currentBranch: '', isGitRepo: false, pickerMode: true, version } + return { + name: '', + path: '', + currentBranch: '', + isGitRepo: false, + pickerMode: true, + version, + hasCommits: false, + rootEntryCount: 0, + } } const isGitRepo = validateRepo(repoPath) if (!isGitRepo) { - return { name: basename(repoPath), path: repoPath, currentBranch: '', isGitRepo: false, pickerMode: false, version } + return { + name: basename(repoPath), + path: repoPath, + currentBranch: '', + isGitRepo: false, + pickerMode: false, + version, + hasCommits: false, + rootEntryCount: 0, + } } let currentBranch = '' currentBranch = getCurrentBranch(repoPath) - return { name: basename(repoPath), path: repoPath, currentBranch, isGitRepo: true, pickerMode: false, version } + return { + name: basename(repoPath), + path: repoPath, + currentBranch, + isGitRepo: true, + pickerMode: false, + version, + hasCommits: hasCommits(repoPath), + rootEntryCount: getBrowseableRootEntryCount(repoPath), + } } export function getBranches(repoPath: string): Branch[] { @@ -130,6 +171,15 @@ export function getCommits(repoPath: string, branch: string, limit: number = 10) } export function findReadme(repoPath: string, branch: string = 'HEAD'): string { + if (isWorkingTreeBranch(repoPath, branch)) { + const workingTreeReadme = listWorkingTreeDirectoryEntries(repoPath) + .map((entry) => entry.path) + .find((filePath) => /^readme(\.\w+)?$/i.test(filePath)) + if (workingTreeReadme) { + return workingTreeReadme + } + } + let output = '' try { output = spawnGit(repoPath, 'ls-tree', '--name-only', branch) diff --git a/src/handlers/git.ts b/src/handlers/git.ts index 67ab5c9..5224a72 100644 --- a/src/handlers/git.ts +++ b/src/handlers/git.ts @@ -14,6 +14,8 @@ export async function infoHandler(c: Context<{ Variables: Variables }>): Promise isGitRepo: false, pickerMode: true, version: getAppVersion(), + hasCommits: false, + rootEntryCount: 0, }) } const info = getInfo(repoPath) diff --git a/src/services/repo-watch.ts b/src/services/repo-watch.ts index 82664d4..ad6da1e 100644 --- a/src/services/repo-watch.ts +++ b/src/services/repo-watch.ts @@ -46,10 +46,7 @@ export function getSyncStatus(repoPath: string, branch: string, currentPath: str resolvedPath, currentPathType, resolvedPathType, - statusMessage: - currentPathType === 'missing' - ? 'The current location is no longer available. GitLocal moved you to the nearest valid path.' - : '', + statusMessage: '', checkedAt, } } diff --git a/src/types.ts b/src/types.ts index dfce1da..eea8756 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,12 +5,16 @@ export interface RepoInfo { isGitRepo: boolean pickerMode: boolean version: string + hasCommits: boolean + rootEntryCount: number } +export type ViewerPathType = 'file' | 'dir' | 'none' + export interface ViewerState { branch: string path: string - pathType: 'file' | 'dir' | 'none' + pathType: ViewerPathType raw: boolean sidebarCollapsed: boolean searchMode: SearchMode diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index c347893..dbabd30 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { chdir } from 'node:process' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' @@ -40,9 +40,31 @@ describe('Server integration', () => { const res = await app.fetch(new Request('http://localhost/api/info')) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('application/json') - const body = await res.json() as { isGitRepo: boolean; version: string } + const body = await res.json() as { isGitRepo: boolean; version: string; hasCommits: boolean; rootEntryCount: number } expect(body.isGitRepo).toBe(true) expect(body.version).toBe(APP_VERSION.version) + expect(body.hasCommits).toBe(true) + expect(body.rootEntryCount).toBeGreaterThan(0) + }) + + it('GET /api/info reports newly initialized repositories as empty landing states', async () => { + const emptyDir = mkdtempSync(join(tmpdir(), 'gitlocal-empty-int-')) + spawnSync('git', ['init'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: emptyDir }) + + try { + const app = createApp(emptyDir) + const res = await app.fetch(new Request('http://localhost/api/info')) + expect(res.status).toBe(200) + const body = await res.json() as { isGitRepo: boolean; currentBranch: string; hasCommits: boolean; rootEntryCount: number } + expect(body.isGitRepo).toBe(true) + expect(body.currentBranch).toBe('') + expect(body.hasCommits).toBe(false) + expect(body.rootEntryCount).toBe(0) + } finally { + rmSync(emptyDir, { recursive: true, force: true }) + } }) it('GET / returns 200 HTML (SPA index)', async () => { @@ -59,6 +81,20 @@ describe('Server integration', () => { expect(Array.isArray(body)).toBe(true) }) + it('GET /api/tree returns immediate child entries for content-panel folder browsing', async () => { + mkdirSync(join(dir, 'docs'), { recursive: true }) + writeFileSync(join(dir, 'docs', 'guide.md'), '# Guide') + writeFileSync(join(dir, 'docs', 'tree-view.md'), '# Notes') + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/tree?path=docs')) + expect(res.status).toBe(200) + const body = await res.json() as Array<{ name: string; path: string; type: 'file' | 'dir' }> + expect(body).toEqual([ + { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + { name: 'tree-view.md', path: 'docs/tree-view.md', type: 'file' }, + ]) + }) + it('GET /unknown-path returns index.html SPA fallback', async () => { const app = createApp(dir) const res = await app.fetch(new Request('http://localhost/some/spa/route')) diff --git a/tests/unit/git/repo.test.ts b/tests/unit/git/repo.test.ts index b56497b..6932e7d 100644 --- a/tests/unit/git/repo.test.ts +++ b/tests/unit/git/repo.test.ts @@ -7,6 +7,8 @@ import { spawnGit, validateRepo, getInfo, + hasCommits, + getBrowseableRootEntryCount, getBranches, getCommits, findReadme, @@ -106,6 +108,8 @@ describe('getInfo', () => { expect(info.pickerMode).toBe(false) expect(info.currentBranch).toBeTruthy() expect(info.name).toBeTruthy() + expect(info.hasCommits).toBe(true) + expect(info.rootEntryCount).toBe(4) } finally { cleanup() } @@ -175,6 +179,31 @@ describe('getInfo — empty repo', () => { const info = getInfo(dir) expect(info.isGitRepo).toBe(true) expect(info.currentBranch).toBe('') + expect(info.hasCommits).toBe(false) + expect(info.rootEntryCount).toBe(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('hasCommits', () => { + it('returns true for a repository with at least one commit', () => { + const { dir, cleanup } = makeGitRepo() + try { + expect(hasCommits(dir)).toBe(true) + } finally { + cleanup() + } + }) + + it('returns false for a repository with no commits yet', () => { + const dir = mkdtempSync(join(tmpdir(), 'empty-git-commits-')) + spawnSync('git', ['init'], { cwd: dir }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir }) + try { + expect(hasCommits(dir)).toBe(false) } finally { rmSync(dir, { recursive: true, force: true }) } @@ -234,6 +263,20 @@ describe('findReadme', () => { cleanup() } }) + + it('finds an uncommitted working-tree README in a newly initialized repository', () => { + const dir = mkdtempSync(join(tmpdir(), 'working-tree-readme-')) + spawnSync('git', ['init'], { cwd: dir }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir }) + writeFileSync(join(dir, 'README.md'), '# Draft readme') + + try { + expect(findReadme(dir, 'HEAD')).toBe('README.md') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) describe('getBranches — failure paths', () => { @@ -460,4 +503,15 @@ describe('working tree helpers', () => { cleanup() } }) + + it('counts only browseable root entries for landing-state decisions', () => { + const { dir, cleanup } = makeGitRepo() + try { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'skip me') + expect(getBrowseableRootEntryCount(dir)).toBe(4) + } finally { + cleanup() + } + }) }) diff --git a/tests/unit/handlers/git.test.ts b/tests/unit/handlers/git.test.ts index 93373c0..0f520f0 100644 --- a/tests/unit/handlers/git.test.ts +++ b/tests/unit/handlers/git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { spawnSync } from 'node:child_process' @@ -55,6 +55,29 @@ describe('infoHandler', () => { expect(body.name).toBeTruthy() expect(body.currentBranch).toBeTruthy() expect(body.version).toBe(APP_VERSION.version) + expect(body.hasCommits).toBe(true) + expect(body.rootEntryCount).toBeGreaterThan(0) + }) + + it('returns empty-repo metadata for a repo with no commits and no browseable entries', async () => { + const emptyDir = mkdtempSync(join(tmpdir(), 'gitlocal-empty-info-')) + spawnSync('git', ['init'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: emptyDir }) + + try { + const app = createApp(emptyDir) + const client = testClient(app) + const res = await client.api.info.$get() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.isGitRepo).toBe(true) + expect(body.currentBranch).toBe('') + expect(body.hasCommits).toBe(false) + expect(body.rootEntryCount).toBe(0) + } finally { + rmSync(emptyDir, { recursive: true, force: true }) + } }) }) @@ -215,4 +238,49 @@ describe('readmeHandler', () => { rmSync(emptyDir, { recursive: true, force: true }) }) + + it('returns a working-tree README path for newly initialized repositories before the first commit', async () => { + const emptyDir = mkdtempSync(join(tmpdir(), 'working-tree-readme-handler-')) + spawnSync('git', ['init'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: emptyDir }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: emptyDir }) + writeFileSync(join(emptyDir, 'README.md'), '# Draft') + + try { + const app = createApp(emptyDir) + const client = testClient(app) + const res = await client.api.readme.$get() + const body = await res.json() + expect(body.path).toBe('README.md') + } finally { + rmSync(emptyDir, { recursive: true, force: true }) + } + }) +}) + +describe('treeHandler', () => { + it('returns immediate child files and folders for the requested directory', async () => { + const repo = makeGitRepo() + const dir = repo.dir + + mkdirSync(join(dir, 'docs', 'nested'), { recursive: true }) + writeFileSync(join(dir, 'docs', 'guide.md'), '# guide') + writeFileSync(join(dir, 'docs', 'notes.md'), '# notes') + writeFileSync(join(dir, 'docs', 'nested', 'child.md'), '# nested') + + try { + const app = createApp(dir) + const client = testClient(app) + const res = await client.api.tree.$get({ query: { path: 'docs' } }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual([ + { name: 'nested', path: 'docs/nested', type: 'dir' }, + { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + { name: 'notes.md', path: 'docs/notes.md', type: 'file' }, + ]) + } finally { + repo.cleanup() + } + }) }) diff --git a/ui/src/App.css b/ui/src/App.css index 20274ae..1aa3a4f 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -665,6 +665,157 @@ body { gap: 12px; align-items: center; text-align: center; + max-width: 720px; +} + +.content-empty-title { + margin: 0; + font-size: 28px; + line-height: 1.15; + color: var(--color-text); +} + +.content-empty-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: center; +} + +.content-directory-intro { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 1040px; + width: 100%; + margin: 0 auto 20px; +} + +.content-directory-title { + margin: 0; + font-size: 24px; + line-height: 1.15; + color: var(--color-text); +} + +.content-directory-detail { + margin: 0; + color: var(--color-text-muted); + line-height: 1.6; +} + +.content-directory-panel { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1040px; + width: 100%; + margin: 0 auto; +} + +.content-directory-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.content-directory-kicker { + margin: 0 0 6px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.content-directory-heading { + margin: 0; + font-size: 24px; + color: var(--color-text); +} + +.content-directory-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.content-directory-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid var(--color-border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 10px 24px rgba(31, 35, 40, 0.04); +} + +.content-directory-row:hover { + border-color: #8c959f; + background: #ffffff; +} + +.content-directory-entry { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.content-directory-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 64px; + padding: 5px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.content-directory-badge-dir { + background: rgba(84, 174, 255, 0.16); + color: #0969da; +} + +.content-directory-badge-file { + background: rgba(101, 109, 118, 0.12); + color: #57606a; +} + +.content-directory-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.content-directory-name { + font-size: 14px; + font-weight: 600; + color: var(--color-text); +} + +.content-directory-path { + font-size: 12px; + color: var(--color-text-muted); + word-break: break-word; +} + +.content-directory-open { + white-space: nowrap; +} + +.content-directory-empty { + border: 1px dashed var(--color-border); + border-radius: 10px; + padding: 24px; + text-align: center; + color: var(--color-text-muted); + background: rgba(246, 248, 250, 0.7); } .content-toolbar { @@ -769,6 +920,16 @@ body { padding: 18px; } +.content-panel-editing { + display: flex; + flex-direction: column; +} + +.manual-editor-expanded { + flex: 1; + min-height: calc(100vh - 260px); +} + .manual-editor-header { display: flex; justify-content: space-between; @@ -821,7 +982,7 @@ body { } .manual-editor-textarea { - min-height: 340px; + min-height: 65vh; resize: vertical; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; line-height: 1.5; @@ -1274,3 +1435,15 @@ body { padding: 2px 5px; border-radius: 3px; } + +@media (max-width: 860px) { + .content-directory-header, + .content-directory-row { + flex-direction: column; + align-items: stretch; + } + + .content-directory-open { + width: 100%; + } +} diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index be75a44..ae4d51c 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -52,6 +52,8 @@ describe('App', () => { isGitRepo: true, pickerMode: false, version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 1, }) vi.mocked(api.getReadme).mockResolvedValue({ path: 'README.md' }) vi.mocked(api.getSyncStatus).mockResolvedValue({ @@ -155,7 +157,7 @@ describe('App', () => { expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument() }) - it('shows a recovery status when sync reports a missing path', async () => { + it('silently recovers when sync reports a missing path', async () => { vi.mocked(api.getSyncStatus).mockResolvedValueOnce({ branch: 'main', repoPath: '/tmp/repo', @@ -173,8 +175,9 @@ describe('App', () => { renderWithClient() await waitFor(() => { - expect(screen.getByRole('status')).toHaveTextContent(/no longer available/i) + expect(api.getFile).not.toHaveBeenCalledWith('docs/guide.md', 'main', true) }) + expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('renders a collapsed navigation rail with an in-panel restore icon', async () => { @@ -235,11 +238,17 @@ describe('App', () => { it('hydrates a saved folder selection without trying to load it as a file', async () => { window.history.replaceState(null, '', '/?branch=main&path=docs&pathType=dir') + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path === 'docs' + ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' }] + : [] + )) renderWithClient() await waitFor(() => { - expect(screen.getByText(/browse files inside/i)).toHaveTextContent('docs') + expect(screen.getByRole('heading', { name: 'docs' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open file guide\.md/i })).toBeInTheDocument() }) expect(api.getFile).not.toHaveBeenCalledWith('docs', 'main', expect.anything()) @@ -264,6 +273,8 @@ describe('App', () => { isGitRepo: false, pickerMode: true, version: APP_VERSION.version, + hasCommits: false, + rootEntryCount: 0, }) renderWithClient() @@ -316,6 +327,8 @@ describe('App', () => { isGitRepo: true, pickerMode: false, version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 1, }) vi.mocked(api.getFile).mockResolvedValueOnce({ path: 'README.md', @@ -338,4 +351,134 @@ describe('App', () => { expect(window.location.search).toContain('path=README.md') expect(window.location.search).not.toContain('docs%2Fguide.md') }) + + it('falls back to the current folder list after switching repositories resets the saved file context', async () => { + window.history.replaceState(null, '', '/?repoPath=%2Ftmp%2Fold-repo&branch=main&path=docs%2Fguide.md&pathType=file') + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/new-repo', + currentBranch: 'main', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 2, + }) + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: '' }) + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path + ? [] + : [ + { name: 'README.md', path: 'README.md', type: 'file' }, + { name: 'docs', path: 'docs', type: 'dir' }, + ] + )) + + renderWithClient() + + expect(await screen.findByRole('status')).toHaveTextContent(/reset the saved file context/i) + expect(screen.queryByRole('heading', { name: /pick up where you left off/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /open file readme\.md/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open folder docs/i })).toBeInTheDocument() + }) + + it('shows an empty-repository landing state when there is no README and no root content', async () => { + window.history.replaceState(null, '', '/') + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/repo', + currentBranch: '', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: false, + rootEntryCount: 0, + }) + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: '' }) + vi.mocked(api.getBranches).mockResolvedValueOnce([]) + + renderWithClient() + + expect(await screen.findByRole('button', { name: /create first file/i })).toBeInTheDocument() + expect(screen.getByText(/newly initialized or empty/i)).toBeInTheDocument() + }) + + it('opens a working-tree README by default before the first commit exists', async () => { + window.history.replaceState(null, '', '/') + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/repo', + currentBranch: '', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: false, + rootEntryCount: 1, + }) + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: 'README.md' }) + vi.mocked(api.getFile).mockResolvedValueOnce({ + path: 'README.md', + type: 'markdown', + content: '# hello', + language: '', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-readme', + }) + + renderWithClient() + + await waitFor(() => { + expect(api.getFile).toHaveBeenCalledWith('README.md', '', false) + }) + }) + + it('clears a stale saved branch when the opened repository has no commits yet', async () => { + window.history.replaceState(null, '', '/?branch=main') + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/repo', + currentBranch: '', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: false, + rootEntryCount: 0, + }) + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: '' }) + vi.mocked(api.getBranches).mockResolvedValueOnce([]) + + renderWithClient() + + expect(await screen.findByRole('status')).toHaveTextContent(/has no commits yet/i) + }) + + it('shows a missing-readme landing state when the repository has content but no README', async () => { + window.history.replaceState(null, '', '/?branch=main') + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/repo', + currentBranch: 'main', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 3, + }) + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: '' }) + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path + ? [] + : [ + { name: 'src', path: 'src', type: 'dir' }, + { name: 'main.ts', path: 'main.ts', type: 'file' }, + ] + )) + + renderWithClient() + + expect(await screen.findByRole('heading', { name: /no readme yet/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open folder src/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open file main\.ts/i })).toBeInTheDocument() + }) }) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f519d95..8e65230 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,10 +9,10 @@ import PickerPage from './components/Picker/PickerPage' import SearchPanel from './components/Search/SearchPanel' import SearchTrigger from './components/Search/SearchTrigger' import AppFooter from './components/AppFooter' -import type { SearchPresentation, SearchResult } from './types' +import type { SearchPresentation, SearchResult, ViewerPathType } from './types' import { readViewerState, writeViewerState } from './services/viewerState' -type SelectedPathType = 'file' | 'dir' | 'none' +type LandingAction = { label: string; action: 'create-file' | 'open-parent' } function PanelToggleIcon({ collapsed }: { collapsed: boolean }) { return collapsed ? ( @@ -30,7 +30,7 @@ export default function App() { const initialViewerState = readViewerState() const [viewerRepoPath, setViewerRepoPath] = useState(initialViewerState.repoPath) const [selectedPath, setSelectedPath] = useState(initialViewerState.path) - const [selectedPathType, setSelectedPathType] = useState(initialViewerState.pathType) + const [selectedPathType, setSelectedPathType] = useState(initialViewerState.pathType) const [currentBranch, setCurrentBranch] = useState(initialViewerState.branch) const [showRaw, setShowRaw] = useState(initialViewerState.raw) const [sidebarCollapsed, setSidebarCollapsed] = useState(initialViewerState.sidebarCollapsed) @@ -71,7 +71,17 @@ export default function App() { }, [info, currentBranch]) useEffect(() => { - if (!info?.isGitRepo || !branches || branches.length === 0 || !currentBranch) return + if (!info?.isGitRepo || !branches) return + + if (branches.length === 0) { + if (currentBranch) { + setCurrentBranch('') + setStatusMessage('GitLocal cleared the saved branch because this repository has no commits yet.') + } + return + } + + if (!currentBranch) return const branchExists = branches.some((branch) => branch.name === currentBranch) if (branchExists) return @@ -108,8 +118,8 @@ export default function App() { setStatusMessage('GitLocal reset the saved file context because you opened a different repository.') lastRevisionRef.current = '' - if (info.currentBranch && currentBranch !== info.currentBranch) { - setCurrentBranch(info.currentBranch) + if (currentBranch !== info.currentBranch) { + setCurrentBranch(info.currentBranch || '') } }, [currentBranch, info, viewerRepoPath]) @@ -284,11 +294,15 @@ export default function App() { setSearchQuery('') } - const canMutateFiles = Boolean(info?.isGitRepo && !hasRepoMismatch && info.currentBranch && currentBranch === info.currentBranch) + const canMutateFiles = Boolean( + info?.isGitRepo + && !hasRepoMismatch + && (!info.currentBranch || currentBranch === info.currentBranch), + ) async function handleMutationComplete(event: { nextPath: string - nextPathType: SelectedPathType + nextPathType: ViewerPathType result: { message: string } }) { setHasUnsavedChanges(false) @@ -302,10 +316,33 @@ export default function App() { } const visibleSelectedPath = hasRepoMismatch ? '' : selectedPath - const visibleSelectedPathType: SelectedPathType = hasRepoMismatch ? 'none' : selectedPathType + const visibleSelectedPathType: ViewerPathType = hasRepoMismatch ? 'none' : selectedPathType const visibleShowRaw = hasRepoMismatch ? false : showRaw - const noReadmePlaceholder = - readmeMissing && !visibleSelectedPath ? 'No README found in this repository.' : undefined + let emptyStateTitle: string | undefined + let emptyStateDetail: string | undefined + let emptyStateActions: LandingAction[] | undefined + + if (!visibleSelectedPath && !hasRepoMismatch) { + if (readmeMissing && info?.rootEntryCount === 0) { + emptyStateTitle = 'This repository is ready for a first file' + emptyStateDetail = 'This repository looks newly initialized or empty, so GitLocal is showing a guided landing state instead of an empty document view.' + emptyStateActions = canMutateFiles + ? [ + { label: 'Create first file', action: 'create-file' }, + { label: 'Browse parent folder', action: 'open-parent' }, + ] + : [{ label: 'Browse parent folder', action: 'open-parent' }] + } else if (readmeMissing) { + emptyStateTitle = 'No README yet' + emptyStateDetail = 'This repository has content, but there is no README to open by default. You can browse the repository tree, create a new file, or return to a parent folder.' + emptyStateActions = canMutateFiles + ? [ + { label: 'Create new file', action: 'create-file' }, + { label: 'Browse parent folder', action: 'open-parent' }, + ] + : [{ label: 'Browse parent folder', action: 'open-parent' }] + } + } return ( <> @@ -417,9 +454,20 @@ export default function App() { selectedPathType={visibleSelectedPathType} branch={currentBranch} onNavigate={handleSelectFile} + onOpenPath={(path, type) => { + if (type === 'dir') { + handleSelectFolder(path) + return + } + handleSelectFile(path) + }} onDirtyChange={setHasUnsavedChanges} onMutationComplete={(event) => { void handleMutationComplete(event) }} - placeholder={noReadmePlaceholder} + placeholder={readmeMissing && !visibleSelectedPath ? 'No README found in this repository.' : undefined} + emptyStateTitle={emptyStateTitle} + emptyStateDetail={emptyStateDetail} + emptyStateActions={emptyStateActions} + onBrowseParent={() => { void handleBrowseParentFolder() }} 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 54dcfee..654ebf1 100644 --- a/ui/src/components/ContentPanel/ContentPanel.test.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.test.tsx @@ -7,20 +7,13 @@ import ContentPanel from './ContentPanel' vi.mock('../../services/api', () => ({ api: { getFile: vi.fn(), + getTree: vi.fn(), createFile: vi.fn(), updateFile: vi.fn(), deleteFile: vi.fn(), }, })) -vi.mock('./MarkdownRenderer', () => ({ - default: ({ onNavigate }: { content: string; onNavigate: (p: string) => void }) => ( -
- -
- ), -})) - vi.mock('./CodeViewer', () => ({ default: ({ content }: { content: string; language: string }) => (
@@ -64,9 +57,10 @@ describe('ContentPanel', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(window, 'confirm').mockReturnValue(true) + vi.mocked(api.getTree).mockResolvedValue([]) }) - it('shows empty state when no filePath', () => { + it('shows empty state when no filePath', async () => { const { container } = renderWithClient( { selectedPathType="none" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) - expect(screen.getByText(/Select a file/i)).toBeInTheDocument() - return expect(axe(container)).resolves.toMatchObject({ violations: [] }) + expect(await screen.findByText(/Select a file/i)).toBeInTheDocument() + await 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', + path: 'README.md', status: 'created', message: 'File created successfully.', }) @@ -101,23 +96,25 @@ describe('ContentPanel', () => { selectedPathType="none" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} onMutationComplete={onMutationComplete} onStatusMessage={onStatusMessage} />, ) - fireEvent.click(screen.getByRole('button', { name: /new file/i })) - fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'docs/new.md' } }) + fireEvent.click(await screen.findByRole('button', { name: /new file/i })) + expect(screen.getByLabelText(/new file path/i)).toHaveValue('README.md') + fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'README.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(api.createFile).toHaveBeenCalledWith({ path: 'README.md', content: '# Draft' }) }) expect(onStatusMessage).toHaveBeenCalledWith('File created successfully.') expect(onMutationComplete).toHaveBeenCalledWith( expect.objectContaining({ - nextPath: 'docs/new.md', + nextPath: 'README.md', nextPathType: 'file', }), ) @@ -134,11 +131,13 @@ describe('ContentPanel', () => { selectedPathType="none" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: /new file/i })) - fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'docs/new.md' } }) + fireEvent.click(await screen.findByRole('button', { name: /new file/i })) + expect(screen.getByLabelText(/new file path/i)).toHaveAttribute('placeholder', 'README.md') + fireEvent.change(screen.getByLabelText(/new file path/i), { target: { value: 'README.md' } }) fireEvent.click(screen.getByRole('button', { name: /create file/i })) expect(await screen.findByRole('alert')).toHaveTextContent(/already exists/i) @@ -147,7 +146,7 @@ describe('ContentPanel', () => { expect(screen.getByText(/select a file/i)).toBeInTheDocument() }) - it('shows folder placeholder and supports creating a file in that folder', async () => { + it('shows a directory list for folders and supports creating a file in that folder', async () => { vi.mocked(api.createFile).mockResolvedValue({ ok: true, operation: 'create', @@ -155,6 +154,9 @@ describe('ContentPanel', () => { status: 'created', message: 'File created successfully.', }) + vi.mocked(api.getTree).mockResolvedValue([ + { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + ]) renderWithClient( { selectedPathType="dir" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) + expect(await screen.findByRole('button', { name: /open file guide\.md/i })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /new file here/i })) - expect(screen.getByLabelText(/new file path/i)).toHaveValue('docs/') + expect(screen.getByLabelText(/new file path/i)).toHaveValue('docs/README.md') }) it('cancels create mode from the draft form', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + ]) + renderWithClient( { selectedPathType="dir" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: /new file here/i })) + fireEvent.click(await screen.findByRole('button', { name: /new file here/i })) fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })) - expect(screen.getByText(/browse files inside/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'docs' })).toBeInTheDocument() + }) + + it('opens directory entries with the row button and double click', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: 'src', path: 'src', type: 'dir' }, + { name: 'main.ts', path: 'main.ts', type: 'file' }, + ]) + + const onOpenPath = vi.fn() + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /open folder src/i })) + fireEvent.doubleClick(screen.getByRole('button', { name: /open file main\.ts/i }).closest('.content-directory-row') as HTMLElement) + + expect(onOpenPath).toHaveBeenNthCalledWith(1, 'src', 'dir') + expect(onOpenPath).toHaveBeenNthCalledWith(2, 'main.ts', 'file') + }) + + it('shows an intentional empty-folder state for selected folders with no entries', async () => { + vi.mocked(api.getTree).mockResolvedValue([]) + + renderWithClient( + , + ) + + expect(await screen.findByText(/does not have any visible files or folders yet/i)).toBeInTheDocument() }) it('shows loading skeleton while fetching', async () => { @@ -200,6 +254,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -217,6 +272,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -236,11 +292,12 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) await waitFor(() => { - expect(screen.getByTestId('markdown-renderer')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument() }) }) @@ -256,6 +313,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} onRawChange={onRawChange} />, ) @@ -298,6 +356,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -314,6 +373,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} /> , ) @@ -346,6 +406,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} onDirtyChange={onDirtyChange} onMutationComplete={onMutationComplete} />, @@ -353,6 +414,8 @@ describe('ContentPanel', () => { await screen.findByRole('button', { name: /edit file/i }) fireEvent.click(screen.getByRole('button', { name: /edit file/i })) + expect(document.querySelector('.content-panel.content-panel-editing')).toBeTruthy() + expect(document.querySelector('.manual-editor-card.manual-editor-expanded')).toBeTruthy() fireEvent.change(screen.getByLabelText(/edit file content/i), { target: { value: 'updated text' } }) await waitFor(() => { @@ -388,6 +451,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -410,6 +474,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -431,6 +496,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -461,6 +527,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} onMutationComplete={onMutationComplete} />, ) @@ -496,6 +563,7 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) @@ -519,17 +587,18 @@ describe('ContentPanel', () => { selectedPathType="none" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} />, ) - fireEvent.click(screen.getByRole('button', { name: /new file/i })) + fireEvent.click(await screen.findByRole('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.getByLabelText(/new file path/i)).toBeInTheDocument() }) - it('shows custom placeholder when filePath empty and placeholder prop provided', () => { + it('shows custom placeholder when filePath empty and placeholder prop provided', async () => { renderWithClient( { selectedPathType="none" branch="main" onNavigate={vi.fn()} + onOpenPath={vi.fn()} placeholder="No README found in this repository." />, ) - expect(screen.getByText('No README found in this repository.')).toBeInTheDocument() + expect(await screen.findByText('No README found in this repository.')).toBeInTheDocument() + }) + + it('renders a structured landing state with title, detail, and actions when provided', async () => { + const onBrowseParent = vi.fn() + + renderWithClient( + , + ) + + expect(await screen.findByRole('heading', { name: /ready for a first file/i })).toBeInTheDocument() + expect(screen.getByText(/guided landing state/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /create first file/i })) + expect(screen.getByLabelText(/new file path/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /back to viewer/i })) + fireEvent.click(screen.getByRole('button', { name: /browse parent folder/i })) + expect(onBrowseParent).toHaveBeenCalledTimes(1) }) it('relative link in MarkdownRenderer calls onNavigate', async () => { - vi.mocked(api.getFile).mockResolvedValue(makeTextFile({ type: 'markdown', language: '', content: '# Hello' })) + vi.mocked(api.getFile).mockResolvedValue( + makeTextFile({ type: 'markdown', language: '', content: '[Guide](docs/guide.md)' }), + ) const onNavigate = vi.fn() @@ -557,15 +661,51 @@ describe('ContentPanel', () => { selectedPathType="file" branch="main" onNavigate={onNavigate} + onOpenPath={vi.fn()} />, ) await waitFor(() => { - expect(screen.getByTestId('markdown-renderer')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Guide' })).toBeInTheDocument() }) - fireEvent.click(screen.getByText('link')) + fireEvent.click(screen.getByRole('link', { name: 'Guide' })) expect(onNavigate).toHaveBeenCalledWith('docs/guide.md') }) + + it('hides markdown comments in rendered mode while raw mode still shows them', async () => { + vi.mocked(api.getFile).mockResolvedValue( + makeTextFile({ + type: 'markdown', + language: '', + content: '# Hello\n\n\n\n[//]: # (hidden reference)\n\nVisible text', + }), + ) + + renderWithClient( + , + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument() + }) + expect(screen.queryByText(/hidden comment/i)).not.toBeInTheDocument() + expect(screen.queryByText(/hidden reference/i)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /view raw/i })) + + await waitFor(() => { + expect(screen.getByText(/hidden comment/i)).toBeInTheDocument() + expect(screen.getByText(/hidden reference/i)).toBeInTheDocument() + }) + }) }) diff --git a/ui/src/components/ContentPanel/ContentPanel.tsx b/ui/src/components/ContentPanel/ContentPanel.tsx index 7132d59..f56f66c 100644 --- a/ui/src/components/ContentPanel/ContentPanel.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.tsx @@ -1,7 +1,7 @@ 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 type { ManualFileOperationResult, TreeNode, ViewerPathType } from '../../types' import CopyButton from './CopyButton' import DeleteFileDialog from './DeleteFileDialog' import InlineFileEditor from './InlineFileEditor' @@ -10,23 +10,29 @@ import NewFileDraft from './NewFileDraft' const MarkdownRenderer = lazy(() => import('./MarkdownRenderer')) const CodeViewer = lazy(() => import('./CodeViewer')) type PanelMode = 'view' | 'edit' | 'create' | 'confirm-delete' +type EmptyStateAction = 'create-file' | 'open-parent' interface FileMutationEvent { result: ManualFileOperationResult nextPath: string - nextPathType: 'file' | 'dir' | 'none' + nextPathType: ViewerPathType } interface Props { canMutateFiles: boolean refreshToken: number selectedPath: string - selectedPathType: 'file' | 'dir' | 'none' + selectedPathType: ViewerPathType branch: string onNavigate: (path: string) => void + onOpenPath: (path: string, type: 'file' | 'dir') => void onDirtyChange?: (value: boolean) => void onMutationComplete?: (event: FileMutationEvent) => void placeholder?: string + emptyStateTitle?: string + emptyStateDetail?: string + emptyStateActions?: Array<{ label: string; action: EmptyStateAction }> + onBrowseParent?: () => void raw?: boolean onRawChange?: (value: boolean) => void onStatusMessage?: (message: string) => void @@ -37,6 +43,15 @@ function parentPathOf(path: string): string { return boundary >= 0 ? path.slice(0, boundary) : '' } +function defaultDraftPath(selectedPath: string, selectedPathType: ViewerPathType): string { + if (selectedPathType === 'dir') return `${selectedPath}/README.md` + if (selectedPathType === 'file') { + const parentPath = parentPathOf(selectedPath) + return parentPath ? `${parentPath}/README.md` : 'README.md' + } + return 'README.md' +} + export default function ContentPanel({ canMutateFiles, refreshToken, @@ -44,9 +59,14 @@ export default function ContentPanel({ selectedPathType, branch, onNavigate, + onOpenPath, onDirtyChange, onMutationComplete, placeholder, + emptyStateTitle, + emptyStateDetail, + emptyStateActions, + onBrowseParent, raw = false, onRawChange, onStatusMessage, @@ -64,6 +84,16 @@ export default function ContentPanel({ queryFn: () => api.getFile(selectedPath, branch, showRaw), enabled: !!selectedPath && selectedPathType === 'file', }) + const directoryPath = selectedPathType === 'dir' ? selectedPath : '' + const showingDirectoryView = selectedPathType === 'dir' || selectedPathType === 'none' + const { + data: directoryEntries, + isLoading: isDirectoryLoading, + } = useQuery({ + queryKey: ['tree', directoryPath, branch, refreshToken, 'content-panel'], + queryFn: () => api.getTree(directoryPath, branch), + enabled: showingDirectoryView, + }) useEffect(() => { setShowRaw(raw) @@ -171,8 +201,7 @@ export default function ContentPanel({ function beginCreateMode(): void { if (!confirmDiscardIfNeeded()) return - const initialPath = selectedPathType === 'dir' ? `${selectedPath}/` : selectedPathType === 'file' ? `${parentPathOf(selectedPath)}/` : '' - setDraftPath(initialPath.replace(/^\/+/, '')) + setDraftPath(defaultDraftPath(selectedPath, selectedPathType).replace(/^\/+/, '')) setDraftContent('') setFormError('') setMode('create') @@ -180,6 +209,122 @@ export default function ContentPanel({ const canToggleRaw = data?.type === 'markdown' || data?.type === 'text' const loadingFallback =
+ const visibleDirectoryEntries = directoryEntries ?? [] + + function renderEmptyStateMessage(title?: string, detail?: string): JSX.Element { + return ( +
+
+ {title ?

{title}

: null} +

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

+ {emptyStateActions && emptyStateActions.length > 0 ? ( +
+ {emptyStateActions.map(({ label, action }) => + action === 'create-file' ? ( + + ) : ( + + ), + )} +
+ ) : canMutateFiles ? ( + + ) : null} +
+
+ ) + } + + function renderDirectoryList(path: string, entries: TreeNode[]): JSX.Element { + const hasIntro = Boolean(emptyStateTitle || emptyStateDetail) + const isRootView = selectedPathType === 'none' + const emptyMessage = isRootView + ? (emptyStateDetail ?? placeholder ?? 'This repository does not have any visible files yet.') + : `This folder does not have any visible files or folders yet.` + + return ( +
+ {hasIntro ? ( +
+ {emptyStateTitle ?

{emptyStateTitle}

: null} + {emptyStateDetail ?

{emptyStateDetail}

: null} + {emptyStateActions && emptyStateActions.length > 0 ? ( +
+ {emptyStateActions.map(({ label, action }) => + action === 'create-file' ? ( + + ) : ( + + ), + )} +
+ ) : null} +
+ ) : null} + +
+
+
+

{path ? 'Folder' : 'Current folder'}

+

{path || 'root'}

+
+ {canMutateFiles ? ( + + ) : null} +
+ + {isDirectoryLoading ? ( +
+ ) : entries.length === 0 ? ( +
+

{emptyMessage}

+
+ ) : ( +
+ {entries.map((entry) => ( +
onOpenPath(entry.path, entry.type)} + > +
+ + {entry.type === 'dir' ? 'Folder' : 'File'} + +
+ {entry.name} + {entry.path} +
+
+ +
+ ))} +
+ )} +
+
+ ) + } if (mode === 'create') { return ( @@ -214,35 +359,23 @@ export default function ContentPanel({ } if (!selectedPath) { - return ( -
-
-

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

- {canMutateFiles && ( - - )} + if (isDirectoryLoading) { + return ( +
+
-
- ) + ) + } + + if (visibleDirectoryEntries.length === 0) { + return renderEmptyStateMessage(emptyStateTitle, emptyStateDetail) + } + + return renderDirectoryList('', visibleDirectoryEntries) } if (selectedPathType === 'dir') { - return ( -
-
-

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

- {canMutateFiles && ( - - )} -
-
- ) + return renderDirectoryList(selectedPath, visibleDirectoryEntries) } if (isLoading) { @@ -262,7 +395,7 @@ export default function ContentPanel({ } return ( -
+
{canMutateFiles && ( <> diff --git a/ui/src/components/ContentPanel/InlineFileEditor.tsx b/ui/src/components/ContentPanel/InlineFileEditor.tsx index 44e7cde..48ed3d0 100644 --- a/ui/src/components/ContentPanel/InlineFileEditor.tsx +++ b/ui/src/components/ContentPanel/InlineFileEditor.tsx @@ -10,7 +10,7 @@ interface Props { export default function InlineFileEditor({ path, content, busy = false, error, onChange, onSave, onCancel }: Props) { return ( -
+

Edit file

diff --git a/ui/src/components/ContentPanel/MarkdownRenderer.tsx b/ui/src/components/ContentPanel/MarkdownRenderer.tsx index ea99d13..87dbd50 100644 --- a/ui/src/components/ContentPanel/MarkdownRenderer.tsx +++ b/ui/src/components/ContentPanel/MarkdownRenderer.tsx @@ -11,6 +11,13 @@ interface Props { onNavigate: (path: string) => void } +function stripHiddenMarkdownComments(content: string): string { + return content + .replace(//g, '') + .replace(/^\[\/\/\]:\s*#\s*\(.*\)\s*$/gm, '') + .replace(/^\[comment\]:\s*#\s*\(.*\)\s*$/gim, '') +} + function flattenText(node: ReactNode): string { if (typeof node === 'string' || typeof node === 'number') return String(node) if (Array.isArray(node)) return node.map(flattenText).join('') @@ -21,6 +28,8 @@ function flattenText(node: ReactNode): string { } export default function MarkdownRenderer({ content, onNavigate }: Props) { + const renderContent = stripHiddenMarkdownComments(content) + return (
- {content} + {renderContent}
) diff --git a/ui/src/components/ContentPanel/NewFileDraft.tsx b/ui/src/components/ContentPanel/NewFileDraft.tsx index 05ad87c..755bb4e 100644 --- a/ui/src/components/ContentPanel/NewFileDraft.tsx +++ b/ui/src/components/ContentPanel/NewFileDraft.tsx @@ -47,7 +47,7 @@ export default function NewFileDraft({ aria-label="New file path" value={path} onChange={(event) => onPathChange(event.target.value)} - placeholder="docs/notes.md" + placeholder="README.md" autoFocus /> diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index d14d2ef..7910c83 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -49,7 +49,7 @@ async function mutate(path: string, method: 'POST' | 'PUT' | 'DELETE', body: } export const api = { - getInfo: (): Promise => request('/api/info'), + getInfo: (): Promise => request('/api/info'), getBranches: (): Promise => request('/api/branches'), diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 234454e..cfe8917 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -5,15 +5,18 @@ export interface RepoInfo { isGitRepo: boolean pickerMode: boolean version: string + hasCommits: boolean + rootEntryCount: number } export type SearchPresentation = 'collapsed' | 'expanded' +export type ViewerPathType = 'file' | 'dir' | 'none' export interface ViewerState { repoPath: string branch: string path: string - pathType: 'file' | 'dir' | 'none' + pathType: ViewerPathType raw: boolean sidebarCollapsed: boolean searchPresentation: SearchPresentation From 566464be8b9ae81373568a7909513d05aaf15cf9 Mon Sep 17 00:00:00 2001 From: ehud Date: Sat, 4 Apr 2026 18:43:19 -0400 Subject: [PATCH 4/4] Refine new file suggestions and button hover states --- ui/src/App.css | 18 ++++- .../ContentPanel/ContentPanel.test.tsx | 25 ++++++- .../components/ContentPanel/ContentPanel.tsx | 66 ++++++++++++++----- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/ui/src/App.css b/ui/src/App.css index 1aa3a4f..b79ebbd 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -843,7 +843,7 @@ body { color: #fff; } -.btn-primary:hover { +.btn-primary:hover:not(:disabled) { background: #0858b8; border-color: #0858b8; } @@ -853,13 +853,25 @@ body { color: #cf222e; } -.btn-danger:hover { +.btn-danger:hover:not(:disabled) { background: rgba(207, 34, 46, 0.08); } -.btn-raw:hover, +.btn-raw:not(.btn-primary):not(.btn-danger):hover:not(:disabled), .copy-button:hover { background: var(--color-hover); } +.btn-raw:disabled, +.copy-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.btn-primary:disabled { + background: #0969da; + border-color: #0969da; + color: #fff; +} + .copy-button { width: 32px; height: 32px; diff --git a/ui/src/components/ContentPanel/ContentPanel.test.tsx b/ui/src/components/ContentPanel/ContentPanel.test.tsx index 654ebf1..e733b8c 100644 --- a/ui/src/components/ContentPanel/ContentPanel.test.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.test.tsx @@ -121,7 +121,7 @@ describe('ContentPanel', () => { }) it('shows create errors and supports returning from create mode', async () => { - vi.mocked(api.createFile).mockRejectedValue({ error: 'That path already exists in the repository.' }) + vi.mocked(api.createFile).mockRejectedValue({ message: 'That path already exists in the repository.' }) renderWithClient( { expect(screen.getByText(/select a file/i)).toBeInTheDocument() }) + it('suggests myfile names when README.md already exists in the folder', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: 'README.md', path: 'README.md', type: 'file' }, + { name: 'myfile.md', path: 'myfile.md', type: 'file' }, + { name: 'myfile 1.md', path: 'myfile 1.md', type: 'file' }, + ]) + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /new file/i })) + expect(screen.getByLabelText(/new file path/i)).toHaveValue('myfile 2.md') + }) + it('shows a directory list for folders and supports creating a file in that folder', async () => { vi.mocked(api.createFile).mockResolvedValue({ ok: true, diff --git a/ui/src/components/ContentPanel/ContentPanel.tsx b/ui/src/components/ContentPanel/ContentPanel.tsx index f56f66c..a0847ba 100644 --- a/ui/src/components/ContentPanel/ContentPanel.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.tsx @@ -43,13 +43,36 @@ function parentPathOf(path: string): string { return boundary >= 0 ? path.slice(0, boundary) : '' } -function defaultDraftPath(selectedPath: string, selectedPathType: ViewerPathType): string { - if (selectedPathType === 'dir') return `${selectedPath}/README.md` - if (selectedPathType === 'file') { - const parentPath = parentPathOf(selectedPath) - return parentPath ? `${parentPath}/README.md` : 'README.md' +function buildSuggestedFilename(entries: TreeNode[]): string { + const fileNames = new Set( + entries + .filter((entry) => entry.type === 'file') + .map((entry) => entry.name.toLowerCase()), + ) + + if (!fileNames.has('readme.md')) return 'README.md' + if (!fileNames.has('myfile.md')) return 'myfile.md' + + let index = 1 + while (fileNames.has(`myfile ${index}.md`)) { + index += 1 } - return 'README.md' + + return `myfile ${index}.md` +} + +function buildSuggestedDraftPath(folderPath: string, entries: TreeNode[]): string { + const filename = buildSuggestedFilename(entries) + return folderPath ? `${folderPath}/${filename}` : filename +} + +function getMutationErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message + if (error && typeof error === 'object') { + const maybePayload = error as { error?: string; message?: string } + return maybePayload.error ?? maybePayload.message ?? fallback + } + return fallback } export default function ContentPanel({ @@ -145,7 +168,7 @@ export default function ContentPanel({ 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.' + const message = getMutationErrorMessage(error, 'Failed to update the file.') setFormError(message) onStatusMessage?.(message) } finally { @@ -168,7 +191,7 @@ export default function ContentPanel({ 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.' + const message = getMutationErrorMessage(error, 'Failed to create the file.') setFormError(message) onStatusMessage?.(message) } finally { @@ -191,7 +214,7 @@ export default function ContentPanel({ 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.' + const message = getMutationErrorMessage(error, 'Failed to delete the file.') setFormError(message) onStatusMessage?.(message) } finally { @@ -199,9 +222,20 @@ export default function ContentPanel({ } } - function beginCreateMode(): void { + async function beginCreateMode(): Promise { if (!confirmDiscardIfNeeded()) return - setDraftPath(defaultDraftPath(selectedPath, selectedPathType).replace(/^\/+/, '')) + const folderPath = selectedPathType === 'dir' ? selectedPath : selectedPathType === 'file' ? parentPathOf(selectedPath) : '' + let entries = folderPath === directoryPath ? visibleDirectoryEntries : null + + if (!entries) { + try { + entries = await api.getTree(folderPath, branch) + } catch { + entries = [] + } + } + + setDraftPath(buildSuggestedDraftPath(folderPath, entries).replace(/^\/+/, '')) setDraftContent('') setFormError('') setMode('create') @@ -221,7 +255,7 @@ export default function ContentPanel({
{emptyStateActions.map(({ label, action }) => action === 'create-file' ? ( - ) : ( @@ -232,7 +266,7 @@ export default function ContentPanel({ )}
) : canMutateFiles ? ( - ) : null} @@ -258,7 +292,7 @@ export default function ContentPanel({
{emptyStateActions.map(({ label, action }) => action === 'create-file' ? ( - ) : ( @@ -279,7 +313,7 @@ export default function ContentPanel({

{path || 'root'}

{canMutateFiles ? ( - ) : null} @@ -415,7 +449,7 @@ export default function ContentPanel({