Changeset 0.21.5 (#91)
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.21.5] — 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Editor Surface (Development Mode).** IDE-like experience built as a v0.21.3 surface consuming workspace primitives. Activates automatically when a channel or project has a bound workspace. Three-pane layout: file tree (sidebar), code editor (CM6), and AI chat panel with resizable split.
|
||||||
|
- **`editor-mode.js`** — Full editor surface module. File tree backed by `workspace_ls` API with nested directory expansion, file icons by extension, and context menus. Tab bar with modified indicators, close buttons, and multi-file editing. CM6 integration with language auto-detection (20+ languages) and textarea fallback when CM6 bundle unavailable.
|
||||||
|
- **Split pane layout** with draggable resize handle. Editor (left) and AI chat (right) share the main region. Chat DOM borrowed from Surfaces saved fragments — real conversation history and input preserved across mode switches.
|
||||||
|
- **Quick Open** (`Ctrl/Cmd+P`): Fuzzy file finder overlay using workspace file index. `Ctrl/Cmd+S` save, `Ctrl/Cmd+W` close tab.
|
||||||
|
- **Live editor updates**: When AI tool calls modify workspace files (`workspace_write`, `workspace_patch`), the server emits `workspace.file.changed` via WebSocket. If the file is open and unmodified in the editor, content refreshes automatically.
|
||||||
|
- **Status bar**: Current file path, language mode, git branch display.
|
||||||
|
- **`editor-mode.css`**: Complete styling for all editor components with dark theme support, mobile responsive (stacked layout at ≤768px).
|
||||||
|
- **Workspace API methods** on frontend `API` module: `getWorkspace`, `listWorkspaceFiles`, `readWorkspaceFile`, `writeWorkspaceFile`, `deleteWorkspaceFile`, `mkdirWorkspace`, `getWorkspaceGitStatus`, `getWorkspaceGitBranches`.
|
||||||
|
- **`Surfaces.getSavedFragment()` / `putSavedFragment()`** — Cross-surface DOM sharing. Allows the editor surface to borrow chat DOM into its split pane without cloning.
|
||||||
|
- **`workspace.file.` event routing** added to server event route table (`DirToClient`).
|
||||||
|
- **Workspace management UI**: Create, list, and bind workspaces from the frontend. Project settings panel gains a Workspace section with dropdown picker and "New" button. Chat context menu gains "Set workspace…" option for per-channel override. Both paths support creating new workspaces inline.
|
||||||
|
- **`GET /workspaces`** — List all workspaces accessible to the current user (user-owned + team-owned).
|
||||||
|
- **`workspace_id` in channel API**: `updateChannelRequest` accepts `workspace_id` for binding/unbinding. `channelResponse` and both list/get queries now include `workspace_id`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `stream_loop.go`: Emits `workspace.file.changed` event after successful `workspace_write`/`workspace_patch` tool calls.
|
||||||
|
- `index.html`: Added `editor-mode.css` stylesheet and `editor-mode.js` script.
|
||||||
|
- `channels.go`: `channelResponse` includes `workspace_id`, list/get queries select it, update accepts it.
|
||||||
|
- `workspaces.go`: Added `List` handler for `GET /workspaces`.
|
||||||
|
- `main.go`: Added `GET /workspaces` route.
|
||||||
|
- `projects-ui.js`: Workspace section in project panel, workspace picker in chat context menu.
|
||||||
|
- `styles.css`: Workspace row layout in project panel.
|
||||||
|
|
||||||
## [0.21.4] — 2026-03-01
|
## [0.21.4] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ v0.21.0 Workspace Storage Primitive ✅
|
|||||||
v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
||||||
Tools + Bindings + REPL (parallel)
|
Tools + Bindings + REPL (parallel)
|
||||||
│ │
|
│ │
|
||||||
v0.21.2 Workspace ✅ v0.21.5 Editor Surface
|
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
|
||||||
Indexing + Search (Development Mode)
|
Indexing + Search (Development Mode)
|
||||||
│ │
|
│ │
|
||||||
v0.21.4 Git ✅ v0.21.6 Article Surface
|
v0.21.4 Git ✅ v0.21.6 Article Surface
|
||||||
@@ -772,15 +772,29 @@ Layer git operations onto workspaces.
|
|||||||
- [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI)
|
- [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI)
|
||||||
- [ ] `.gitignore` respect in workspace indexing (deferred to v0.21.5+)
|
- [ ] `.gitignore` respect in workspace indexing (deferred to v0.21.5+)
|
||||||
|
|
||||||
### v0.21.5 — Editor Surface (Development Mode)
|
### v0.21.5 — Editor Surface (Development Mode) ✅
|
||||||
|
|
||||||
IDE-like experience. Browser-tier extension consuming workspace primitives.
|
IDE-like experience. Browser-tier surface consuming workspace primitives.
|
||||||
|
|
||||||
- [ ] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar
|
- [x] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar
|
||||||
- [ ] File tree: workspace_ls backed, git status indicators, context menu, drag-drop
|
- [x] File tree: workspace_ls backed, context menu (open, delete), nested directory expansion, file icons
|
||||||
- [ ] Code editor: CM6 codeEditor() factory, language auto-detection, tab bar, Ctrl+S save
|
- [x] Code editor: CM6 codeEditor() factory with textarea fallback, language auto-detection (20+ languages), tab bar, Ctrl+S save
|
||||||
- [ ] AI chat panel: same channel context, workspace tools auto-injected, live update via EventBus
|
- [x] AI chat panel: same channel context via Surfaces DOM borrowing, live update via workspace.file.changed event
|
||||||
- [ ] Keyboard shortcuts: Ctrl+P quick open, Ctrl+Shift+F workspace search
|
- [x] Keyboard shortcuts: Ctrl+P quick open, Ctrl+S save, Ctrl+W close tab
|
||||||
|
- [x] Split pane layout with draggable resize handle
|
||||||
|
- [x] Status bar: file path, language mode, git branch
|
||||||
|
- [x] Workspace API methods on frontend (8 methods)
|
||||||
|
- [x] Surfaces.getSavedFragment/putSavedFragment for cross-surface DOM sharing
|
||||||
|
- [x] workspace.file.changed event emission from stream_loop.go after tool calls
|
||||||
|
- [x] Event route table: workspace.file. → DirToClient
|
||||||
|
- [x] editor-mode.css: dark theme, mobile responsive (stacked at ≤768px)
|
||||||
|
- [x] Auto-registration: surface registers when channel/project has workspace binding
|
||||||
|
- [x] Workspace management UI: create, list, bind from project settings + chat context menu
|
||||||
|
- [x] GET /workspaces list endpoint, workspace_id in channel API (update, get, list)
|
||||||
|
- [ ] Git status indicators in file tree (visual indicators per-file) — deferred
|
||||||
|
- [ ] Drag-drop file reorder — deferred
|
||||||
|
- [ ] Ctrl+Shift+F workspace search — deferred
|
||||||
|
- [ ] Auto-save on tab/mode switch — deferred
|
||||||
|
|
||||||
### v0.21.6 — Article Surface + Document Output Pipeline
|
### v0.21.6 — Article Surface + Document Output Pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ var routeTable = map[string]Direction{
|
|||||||
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
|
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
|
||||||
"notification.read": DirToClient, // badge sync across tabs
|
"notification.read": DirToClient, // badge sync across tabs
|
||||||
|
|
||||||
|
// Workspace (v0.21.5)
|
||||||
|
"workspace.file.": DirToClient, // file changed events for live editor updates
|
||||||
|
|
||||||
// Plugin hooks — never cross the wire
|
// Plugin hooks — never cross the wire
|
||||||
"plugin.hook.": DirLocal,
|
"plugin.hook.": DirLocal,
|
||||||
"internal.": DirLocal,
|
"internal.": DirLocal,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type updateChannelRequest struct {
|
|||||||
Folder *string `json:"folder,omitempty"`
|
Folder *string `json:"folder,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||||
|
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelResponse struct {
|
type channelResponse struct {
|
||||||
@@ -54,6 +55,7 @@ type channelResponse struct {
|
|||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
Folder *string `json:"folder"`
|
Folder *string `json:"folder"`
|
||||||
ProjectID *string `json:"project_id,omitempty"`
|
ProjectID *string `json:"project_id,omitempty"`
|
||||||
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Settings json.RawMessage `json:"settings,omitempty"`
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
@@ -178,7 +180,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
// Fetch channels with message count
|
// Fetch channels with message count
|
||||||
query := `
|
query := `
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id,
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
||||||
c.tags, c.settings,
|
c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
@@ -230,7 +232,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
var tags []string
|
var tags []string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
scanTags(&tags), scanJSON(&ch.Settings),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -377,7 +379,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
var tags []string
|
var tags []string
|
||||||
err := database.DB.QueryRow(database.Q(`
|
err := database.DB.QueryRow(database.Q(`
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id,
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
||||||
c.tags, c.settings,
|
c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
@@ -388,7 +390,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
WHERE c.id = $1 AND c.user_id = $2
|
WHERE c.id = $1 AND c.user_id = $2
|
||||||
`), channelID, userID).Scan(
|
`), channelID, userID).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
scanTags(&tags), scanJSON(&ch.Settings),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -484,6 +486,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
args = append(args, []byte(*req.Settings))
|
args = append(args, []byte(*req.Settings))
|
||||||
}
|
}
|
||||||
|
if req.WorkspaceID != nil {
|
||||||
|
if *req.WorkspaceID == "" {
|
||||||
|
addClause("workspace_id", nil) // unbind
|
||||||
|
} else {
|
||||||
|
addClause("workspace_id", *req.WorkspaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(setClauses) == 0 {
|
if len(setClauses) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
|
|||||||
@@ -207,6 +207,22 @@ func streamWithToolLoop(
|
|||||||
})
|
})
|
||||||
sendEvent("tool_result", string(resultJSON))
|
sendEvent("tool_result", string(resultJSON))
|
||||||
|
|
||||||
|
// Emit workspace.file.changed for live editor updates (v0.21.5)
|
||||||
|
if hub != nil && !toolResult.IsError && workspaceID != "" {
|
||||||
|
if call.Name == "workspace_write" || call.Name == "workspace_patch" {
|
||||||
|
var toolArgs struct{ Path string `json:"path"` }
|
||||||
|
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
|
||||||
|
hub.SendToUser(userID, events.Event{
|
||||||
|
Label: "workspace.file.changed",
|
||||||
|
Payload: events.MustJSON(map[string]string{
|
||||||
|
"workspace_id": workspaceID,
|
||||||
|
"path": toolArgs.Path,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collect for persistence
|
// Collect for persistence
|
||||||
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
||||||
"id": tc.ID,
|
"id": tc.ID,
|
||||||
|
|||||||
@@ -101,6 +101,34 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, w)
|
c.JSON(http.StatusOK, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns all workspaces accessible to the current user.
|
||||||
|
// Includes user-owned workspaces and those owned by the user's teams.
|
||||||
|
func (h *WorkspaceHandler) List(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// Fetch user-owned workspaces
|
||||||
|
userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
all := make([]models.Workspace, 0, len(userWs))
|
||||||
|
all = append(all, userWs...)
|
||||||
|
|
||||||
|
// Also include team-owned workspaces
|
||||||
|
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||||
|
for _, tid := range teamIDs {
|
||||||
|
teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid)
|
||||||
|
if err == nil {
|
||||||
|
all = append(all, teamWs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, all)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||||
w, ok := h.loadAndAuthorize(c)
|
w, ok := h.loadAndAuthorize(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ func main() {
|
|||||||
if wfs != nil {
|
if wfs != nil {
|
||||||
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||||
protected.POST("/workspaces", wsH.Create)
|
protected.POST("/workspaces", wsH.Create)
|
||||||
|
protected.GET("/workspaces", wsH.List)
|
||||||
protected.GET("/workspaces/:id", wsH.Get)
|
protected.GET("/workspaces/:id", wsH.Get)
|
||||||
protected.PATCH("/workspaces/:id", wsH.Update)
|
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||||
protected.DELETE("/workspaces/:id", wsH.Delete)
|
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||||
|
|||||||
229
src/css/editor-mode.css
Normal file
229
src/css/editor-mode.css
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/* ==========================================
|
||||||
|
Editor Surface (v0.21.5)
|
||||||
|
IDE-like layout: file tree | code + chat
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 12px; gap: 8px;
|
||||||
|
font-size: 13px; color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 36px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
|
||||||
|
.editor-header-right { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.editor-hdr-btn {
|
||||||
|
background: none; border: none; color: var(--text-secondary);
|
||||||
|
cursor: pointer; padding: 4px; border-radius: 4px;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── File Tree (sidebar-content region) ──── */
|
||||||
|
|
||||||
|
.editor-file-tree {
|
||||||
|
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||||
|
padding: 4px 0; font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.editor-tree-row {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 3px 8px; cursor: pointer;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
border-radius: 4px; margin: 0 4px;
|
||||||
|
}
|
||||||
|
.editor-tree-row:hover { background: var(--hover); }
|
||||||
|
.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); }
|
||||||
|
.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
.editor-tree-arrow.expanded { /* already rotated via character */ }
|
||||||
|
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
||||||
|
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
||||||
|
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Split Layout ───────────────────── */
|
||||||
|
|
||||||
|
.editor-main {
|
||||||
|
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-left-pane {
|
||||||
|
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-right-pane {
|
||||||
|
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handle */
|
||||||
|
.editor-split-handle {
|
||||||
|
width: 5px; cursor: col-resize; flex-shrink: 0;
|
||||||
|
background: transparent; position: relative; z-index: 5;
|
||||||
|
}
|
||||||
|
.editor-split-handle:hover,
|
||||||
|
.editor-split-handle:active {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab Bar ─────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-tab-bar {
|
||||||
|
display: flex; overflow-x: auto; overflow-y: hidden;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 32px; flex-shrink: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.editor-tab-bar::-webkit-scrollbar { height: 0; }
|
||||||
|
|
||||||
|
.editor-tab {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 4px 10px; font-size: 12px;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0; max-width: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.editor-tab:hover { background: var(--hover); }
|
||||||
|
.editor-tab.active {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
.editor-tab.modified .editor-tab-name { font-style: italic; }
|
||||||
|
.editor-tab-icon { font-size: 13px; flex-shrink: 0; }
|
||||||
|
.editor-tab-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; }
|
||||||
|
.editor-tab-close {
|
||||||
|
background: none; border: none; color: var(--text-tertiary);
|
||||||
|
cursor: pointer; font-size: 11px; padding: 0 2px;
|
||||||
|
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.editor-tab:hover .editor-tab-close { opacity: 1; }
|
||||||
|
.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Code Editor Area ────────────────────── */
|
||||||
|
|
||||||
|
.editor-code-area {
|
||||||
|
flex: 1; min-height: 0; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-cm-wrap {
|
||||||
|
position: absolute; inset: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-cm-wrap .cm-editor { height: 100%; }
|
||||||
|
.editor-cm-wrap .cm-scroller { overflow: auto; }
|
||||||
|
|
||||||
|
/* Textarea fallback */
|
||||||
|
.editor-textarea-fallback {
|
||||||
|
width: 100%; height: 100%; resize: none; border: none; outline: none;
|
||||||
|
background: var(--bg-primary); color: var(--text-primary);
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 13px; line-height: 1.5;
|
||||||
|
padding: 8px 12px; box-sizing: border-box;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome screen */
|
||||||
|
.editor-welcome {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.editor-welcome-inner { text-align: center; color: var(--text-tertiary); }
|
||||||
|
.editor-welcome-inner p { margin: 8px 0; font-size: 14px; }
|
||||||
|
.editor-welcome-hint { font-size: 12px; opacity: 0.6; }
|
||||||
|
.editor-welcome-hint kbd {
|
||||||
|
background: var(--hover); border: 1px solid var(--border);
|
||||||
|
border-radius: 3px; padding: 1px 5px; font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat Panel (right pane) ─────────────── */
|
||||||
|
|
||||||
|
.editor-chat-header {
|
||||||
|
padding: 6px 12px; font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--text-secondary); border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.editor-chat-messages {
|
||||||
|
flex: 1; overflow-y: auto; min-height: 0;
|
||||||
|
}
|
||||||
|
/* Inherit chat message styles when embedded */
|
||||||
|
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
||||||
|
.editor-chat-input { flex-shrink: 0; }
|
||||||
|
/* Inherit input-area styles */
|
||||||
|
.editor-chat-input .input-area { border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── Status Bar (footer) ─────────────────── */
|
||||||
|
|
||||||
|
.editor-status-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 2px 12px; font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
min-height: 22px; flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.editor-status-sep { opacity: 0.3; }
|
||||||
|
.editor-status-right { margin-left: auto; }
|
||||||
|
.editor-status-branch { font-size: 11px; }
|
||||||
|
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Context Menu ────────────────────────── */
|
||||||
|
|
||||||
|
.editor-ctx-menu {
|
||||||
|
position: fixed; z-index: 1000;
|
||||||
|
background: var(--bg-primary); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 4px 0;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||||
|
min-width: 120px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.editor-ctx-item {
|
||||||
|
padding: 6px 12px; cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.editor-ctx-item:hover { background: var(--hover); }
|
||||||
|
|
||||||
|
/* ── Quick Open Overlay ──────────────────── */
|
||||||
|
|
||||||
|
.editor-quick-open {
|
||||||
|
position: fixed; inset: 0; z-index: 2000;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
}
|
||||||
|
.editor-qo-dialog {
|
||||||
|
background: var(--bg-primary); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; width: 460px; max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-qo-input {
|
||||||
|
width: 100%; padding: 10px 14px; font-size: 14px;
|
||||||
|
border: none; outline: none; box-sizing: border-box;
|
||||||
|
background: var(--bg-primary); color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.editor-qo-results { max-height: 300px; overflow-y: auto; }
|
||||||
|
.editor-qo-row {
|
||||||
|
padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.editor-qo-row:hover { background: var(--hover); }
|
||||||
|
.editor-qo-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Mobile: single pane ─────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-main { flex-direction: column; }
|
||||||
|
.editor-left-pane { flex: 1 !important; min-width: 0; }
|
||||||
|
.editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||||
|
.editor-split-handle { display: none; }
|
||||||
|
}
|
||||||
@@ -2583,6 +2583,8 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
}
|
}
|
||||||
.project-panel-select:focus { outline: none; border-color: var(--accent); }
|
.project-panel-select:focus { outline: none; border-color: var(--accent); }
|
||||||
.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||||
|
.project-panel-ws-row { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.project-panel-ws-row select { flex: 1; }
|
||||||
.project-panel-checkbox {
|
.project-panel-checkbox {
|
||||||
display: flex; align-items: center; gap: 8px; font-size: 13px;
|
display: flex; align-items: center; gap: 8px; font-size: 13px;
|
||||||
cursor: pointer; color: var(--text-2);
|
cursor: pointer; color: var(--text-2);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||||
|
<link rel="stylesheet" href="css/editor-mode.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
|
||||||
@@ -1201,6 +1202,7 @@
|
|||||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/editor-mode.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Service Worker Registration ─────────
|
// ── Service Worker Registration ─────────
|
||||||
|
|||||||
@@ -207,6 +207,43 @@ const API = {
|
|||||||
},
|
},
|
||||||
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||||
|
|
||||||
|
// ── Workspaces (v0.21.0) ────────────────
|
||||||
|
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
|
||||||
|
listWorkspaceFiles(wsId, path = '', recursive = false) {
|
||||||
|
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
|
||||||
|
if (recursive) url += '&recursive=true';
|
||||||
|
return this._get(url);
|
||||||
|
},
|
||||||
|
async readWorkspaceFile(wsId, path) {
|
||||||
|
// Returns raw text, not JSON
|
||||||
|
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
|
||||||
|
}
|
||||||
|
const ct = resp.headers.get('content-type') || '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.content || '';
|
||||||
|
}
|
||||||
|
return resp.text();
|
||||||
|
},
|
||||||
|
writeWorkspaceFile(wsId, path, content) {
|
||||||
|
return this._put(`/api/v1/workspaces/${wsId}/files/write`, { path, content });
|
||||||
|
},
|
||||||
|
deleteWorkspaceFile(wsId, path) {
|
||||||
|
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
|
||||||
|
},
|
||||||
|
mkdirWorkspace(wsId, path) {
|
||||||
|
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
|
||||||
|
},
|
||||||
|
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
|
||||||
|
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
|
||||||
|
listWorkspaces() { return this._get('/api/v1/workspaces'); },
|
||||||
|
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
|
||||||
|
|
||||||
// ── Messages ─────────────────────────────
|
// ── Messages ─────────────────────────────
|
||||||
|
|
||||||
listMessages(channelId, page = 1, perPage = 200) {
|
listMessages(channelId, page = 1, perPage = 200) {
|
||||||
|
|||||||
936
src/js/editor-mode.js
Normal file
936
src/js/editor-mode.js
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Editor Surface (v0.21.5)
|
||||||
|
// ==========================================
|
||||||
|
// IDE-like experience built as a surface consuming workspace
|
||||||
|
// primitives (v0.21.0) and surface infrastructure (v0.21.3).
|
||||||
|
//
|
||||||
|
// Layout: file tree (sidebar) | code editor (CM6) + chat panel (split)
|
||||||
|
// Load order: surfaces.js → editor-mode.js (after app init)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const EditorMode = {
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────
|
||||||
|
_wsId: null, // workspace ID
|
||||||
|
_wsName: '', // workspace display name
|
||||||
|
_gitBranch: null, // current git branch (nullable)
|
||||||
|
_registered: false, // surface registered?
|
||||||
|
_active: false, // currently active?
|
||||||
|
|
||||||
|
// DOM (built once, reused across activations)
|
||||||
|
_els: null, // { header, fileTree, main, footer }
|
||||||
|
_built: false,
|
||||||
|
|
||||||
|
// File tree
|
||||||
|
_treeData: [], // flat file list from API
|
||||||
|
_expandedDirs: new Set(['']), // expanded directory paths
|
||||||
|
|
||||||
|
// Tabs + editors
|
||||||
|
_openFiles: new Map(), // path → { tab, editorWrap, editor(CM6), content, modified, language }
|
||||||
|
_activeFile: null, // path of currently active tab
|
||||||
|
_tabBarEl: null,
|
||||||
|
_editorAreaEl: null,
|
||||||
|
_welcomeEl: null,
|
||||||
|
|
||||||
|
// Split
|
||||||
|
_splitRatio: 0.6, // editor pane width ratio (0.0-1.0)
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current channel/project has a workspace.
|
||||||
|
* Called on channel switch and app init.
|
||||||
|
*/
|
||||||
|
async check() {
|
||||||
|
const channelId = App.currentChatId;
|
||||||
|
if (!channelId) {
|
||||||
|
this._unregister();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ch = await API.getChannel(channelId);
|
||||||
|
const wsId = ch.workspace_id || null;
|
||||||
|
|
||||||
|
// Also check project workspace if channel doesn't have one
|
||||||
|
if (!wsId && ch.project_id) {
|
||||||
|
try {
|
||||||
|
const proj = await API.getProject(ch.project_id);
|
||||||
|
if (proj.workspace_id) {
|
||||||
|
this._register(proj.workspace_id, proj.name || 'Workspace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) { /* no project workspace */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsId) {
|
||||||
|
// Fetch workspace details
|
||||||
|
try {
|
||||||
|
const ws = await API.getWorkspace(wsId);
|
||||||
|
this._register(wsId, ws.name || 'Workspace');
|
||||||
|
} catch (_) {
|
||||||
|
this._register(wsId, 'Workspace');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._unregister();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EditorMode] check failed:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_register(wsId, name) {
|
||||||
|
this._wsId = wsId;
|
||||||
|
this._wsName = name;
|
||||||
|
|
||||||
|
if (this._registered) return;
|
||||||
|
this._registered = true;
|
||||||
|
|
||||||
|
Surfaces.register('editor', {
|
||||||
|
label: 'Editor',
|
||||||
|
icon: 'code',
|
||||||
|
regions: ['surface-header', 'surface-main', 'surface-footer', 'sidebar-content'],
|
||||||
|
activate: () => this._activate(),
|
||||||
|
deactivate: () => this._deactivate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EditorMode] Registered for workspace ${wsId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
_unregister() {
|
||||||
|
if (!this._registered) return;
|
||||||
|
if (this._active) {
|
||||||
|
Surfaces.activate('chat');
|
||||||
|
}
|
||||||
|
Surfaces.unregister('editor');
|
||||||
|
this._registered = false;
|
||||||
|
this._wsId = null;
|
||||||
|
this._built = false;
|
||||||
|
this._els = null;
|
||||||
|
this._openFiles.clear();
|
||||||
|
this._activeFile = null;
|
||||||
|
console.log('[EditorMode] Unregistered');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Surface Callbacks ────────────────────
|
||||||
|
|
||||||
|
_activate() {
|
||||||
|
this._active = true;
|
||||||
|
|
||||||
|
if (!this._built) {
|
||||||
|
this._build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate regions
|
||||||
|
const regions = Surfaces._regionEls;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const headerEl = regions.get('surface-header');
|
||||||
|
if (headerEl) headerEl.appendChild(this._els.header);
|
||||||
|
|
||||||
|
// Sidebar → file tree
|
||||||
|
const sidebarEl = regions.get('sidebar-content');
|
||||||
|
if (sidebarEl) sidebarEl.appendChild(this._els.fileTree);
|
||||||
|
|
||||||
|
// Main → split pane (editor + chat)
|
||||||
|
const mainEl = regions.get('surface-main');
|
||||||
|
if (mainEl) mainEl.appendChild(this._els.main);
|
||||||
|
|
||||||
|
// Footer → status bar
|
||||||
|
const footerEl = regions.get('surface-footer');
|
||||||
|
if (footerEl) footerEl.appendChild(this._els.footer);
|
||||||
|
|
||||||
|
// Embed saved chat DOM into our chat pane
|
||||||
|
this._embedChat();
|
||||||
|
|
||||||
|
// Refresh file tree
|
||||||
|
this._refreshFileTree();
|
||||||
|
this._refreshGitBranch();
|
||||||
|
|
||||||
|
// Focus active editor
|
||||||
|
if (this._activeFile) {
|
||||||
|
const f = this._openFiles.get(this._activeFile);
|
||||||
|
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_deactivate() {
|
||||||
|
this._active = false;
|
||||||
|
|
||||||
|
// Return chat DOM to Surfaces saved store before regions are saved
|
||||||
|
this._returnChat();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Chat Panel Embedding ─────────────────
|
||||||
|
// Borrow chat DOM from the saved fragments so the chat panel
|
||||||
|
// shows real messages and the user can interact with AI.
|
||||||
|
|
||||||
|
_chatPane: null,
|
||||||
|
_chatMessagesSlot: null,
|
||||||
|
_chatInputSlot: null,
|
||||||
|
|
||||||
|
_embedChat() {
|
||||||
|
if (!this._chatPane) return;
|
||||||
|
|
||||||
|
// Grab saved chat fragments
|
||||||
|
const msgFrag = Surfaces.getSavedFragment('chat', 'surface-main');
|
||||||
|
const inputFrag = Surfaces.getSavedFragment('chat', 'surface-footer');
|
||||||
|
|
||||||
|
if (msgFrag) {
|
||||||
|
this._chatMessagesSlot.appendChild(msgFrag);
|
||||||
|
// Scroll to bottom
|
||||||
|
this._chatMessagesSlot.scrollTop = this._chatMessagesSlot.scrollHeight;
|
||||||
|
}
|
||||||
|
if (inputFrag) {
|
||||||
|
this._chatInputSlot.appendChild(inputFrag);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_returnChat() {
|
||||||
|
if (!this._chatPane) return;
|
||||||
|
|
||||||
|
// Collect chat DOM back into fragments and return to Surfaces store
|
||||||
|
const msgFrag = document.createDocumentFragment();
|
||||||
|
while (this._chatMessagesSlot.firstChild) {
|
||||||
|
msgFrag.appendChild(this._chatMessagesSlot.firstChild);
|
||||||
|
}
|
||||||
|
Surfaces.putSavedFragment('chat', 'surface-main', msgFrag);
|
||||||
|
|
||||||
|
const inputFrag = document.createDocumentFragment();
|
||||||
|
while (this._chatInputSlot.firstChild) {
|
||||||
|
inputFrag.appendChild(this._chatInputSlot.firstChild);
|
||||||
|
}
|
||||||
|
Surfaces.putSavedFragment('chat', 'surface-footer', inputFrag);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DOM Construction ─────────────────────
|
||||||
|
|
||||||
|
_build() {
|
||||||
|
this._els = {
|
||||||
|
header: this._buildHeader(),
|
||||||
|
fileTree: this._buildFileTreeContainer(),
|
||||||
|
main: this._buildMain(),
|
||||||
|
footer: this._buildFooter(),
|
||||||
|
};
|
||||||
|
this._built = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildHeader() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'editor-header';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="editor-header-left">
|
||||||
|
<span class="editor-ws-name" id="editorWsName">${this._esc(this._wsName)}</span>
|
||||||
|
<span class="editor-branch" id="editorBranch"></span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-header-right">
|
||||||
|
<button class="editor-hdr-btn" id="editorRefreshBtn" title="Refresh file tree">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
el.querySelector('#editorRefreshBtn').addEventListener('click', () => this._refreshFileTree());
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildFileTreeContainer() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'editor-file-tree';
|
||||||
|
el.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildMain() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'editor-main';
|
||||||
|
|
||||||
|
// Left: editor area (tabs + code)
|
||||||
|
const leftPane = document.createElement('div');
|
||||||
|
leftPane.className = 'editor-left-pane';
|
||||||
|
|
||||||
|
this._tabBarEl = document.createElement('div');
|
||||||
|
this._tabBarEl.className = 'editor-tab-bar';
|
||||||
|
leftPane.appendChild(this._tabBarEl);
|
||||||
|
|
||||||
|
this._editorAreaEl = document.createElement('div');
|
||||||
|
this._editorAreaEl.className = 'editor-code-area';
|
||||||
|
leftPane.appendChild(this._editorAreaEl);
|
||||||
|
|
||||||
|
// Welcome screen
|
||||||
|
this._welcomeEl = document.createElement('div');
|
||||||
|
this._welcomeEl.className = 'editor-welcome';
|
||||||
|
this._welcomeEl.innerHTML = `
|
||||||
|
<div class="editor-welcome-inner">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
<p>Select a file to start editing</p>
|
||||||
|
<p class="editor-welcome-hint"><kbd>Ctrl+P</kbd> Quick Open</p>
|
||||||
|
</div>`;
|
||||||
|
this._editorAreaEl.appendChild(this._welcomeEl);
|
||||||
|
|
||||||
|
// Resize handle
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'editor-split-handle';
|
||||||
|
this._initResize(handle, leftPane);
|
||||||
|
|
||||||
|
// Right: chat panel
|
||||||
|
const rightPane = document.createElement('div');
|
||||||
|
rightPane.className = 'editor-right-pane';
|
||||||
|
|
||||||
|
const chatHeader = document.createElement('div');
|
||||||
|
chatHeader.className = 'editor-chat-header';
|
||||||
|
chatHeader.innerHTML = '<span>AI Chat</span>';
|
||||||
|
rightPane.appendChild(chatHeader);
|
||||||
|
|
||||||
|
this._chatMessagesSlot = document.createElement('div');
|
||||||
|
this._chatMessagesSlot.className = 'editor-chat-messages';
|
||||||
|
rightPane.appendChild(this._chatMessagesSlot);
|
||||||
|
|
||||||
|
this._chatInputSlot = document.createElement('div');
|
||||||
|
this._chatInputSlot.className = 'editor-chat-input';
|
||||||
|
rightPane.appendChild(this._chatInputSlot);
|
||||||
|
|
||||||
|
this._chatPane = rightPane;
|
||||||
|
|
||||||
|
el.appendChild(leftPane);
|
||||||
|
el.appendChild(handle);
|
||||||
|
el.appendChild(rightPane);
|
||||||
|
|
||||||
|
// Apply initial split ratio
|
||||||
|
leftPane.style.flex = `0 0 ${this._splitRatio * 100}%`;
|
||||||
|
rightPane.style.flex = '1';
|
||||||
|
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildFooter() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'editor-status-bar';
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="editor-status-file" id="editorStatusFile">No file open</span>
|
||||||
|
<span class="editor-status-sep">│</span>
|
||||||
|
<span class="editor-status-cursor" id="editorStatusCursor"></span>
|
||||||
|
<span class="editor-status-sep">│</span>
|
||||||
|
<span class="editor-status-lang" id="editorStatusLang"></span>
|
||||||
|
<span class="editor-status-right">
|
||||||
|
<span class="editor-status-branch" id="editorStatusBranch"></span>
|
||||||
|
</span>`;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Resize Handle ────────────────────────
|
||||||
|
|
||||||
|
_initResize(handle, leftPane) {
|
||||||
|
let startX = 0, startWidth = 0;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dx = (e.clientX || e.touches?.[0]?.clientX || 0) - startX;
|
||||||
|
const parentWidth = handle.parentElement.clientWidth;
|
||||||
|
const newWidth = Math.max(200, Math.min(parentWidth - 200, startWidth + dx));
|
||||||
|
leftPane.style.flex = `0 0 ${newWidth}px`;
|
||||||
|
this._splitRatio = newWidth / parentWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onUp);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidth = leftPane.clientWidth;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('touchstart', (e) => {
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
|
startWidth = leftPane.clientWidth;
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onUp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── File Tree ────────────────────────────
|
||||||
|
|
||||||
|
async _refreshFileTree() {
|
||||||
|
if (!this._wsId) return;
|
||||||
|
try {
|
||||||
|
const resp = await API.listWorkspaceFiles(this._wsId, '', true);
|
||||||
|
this._treeData = resp.files || resp.data || resp || [];
|
||||||
|
this._renderFileTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EditorMode] Failed to load file tree:', e);
|
||||||
|
this._els.fileTree.innerHTML = '<div class="editor-tree-error">Failed to load files</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderFileTree() {
|
||||||
|
const container = this._els.fileTree;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this._treeData.length) {
|
||||||
|
container.innerHTML = '<div class="editor-tree-empty">No files</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree structure from flat list
|
||||||
|
const tree = this._buildTreeStructure(this._treeData);
|
||||||
|
this._renderTreeNodes(tree, container, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildTreeStructure(files) {
|
||||||
|
// files: [{path, is_directory, size_bytes, content_type}, ...]
|
||||||
|
// Build nested tree: { name, path, isDir, children[], file }
|
||||||
|
const root = { name: '', path: '', isDir: true, children: [], file: null };
|
||||||
|
const dirs = new Map();
|
||||||
|
dirs.set('', root);
|
||||||
|
|
||||||
|
// Sort: directories first, then alphabetical
|
||||||
|
const sorted = [...files].sort((a, b) => {
|
||||||
|
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||||
|
return a.path.localeCompare(b.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of sorted) {
|
||||||
|
const parts = f.path.split('/');
|
||||||
|
const name = parts[parts.length - 1];
|
||||||
|
const parentPath = parts.slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Ensure parent directories exist
|
||||||
|
let parent = dirs.get(parentPath);
|
||||||
|
if (!parent) {
|
||||||
|
// Create implicit parent dirs
|
||||||
|
let accumulated = '';
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const prev = accumulated;
|
||||||
|
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
|
||||||
|
if (!dirs.has(accumulated)) {
|
||||||
|
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
|
||||||
|
dirs.set(accumulated, dirNode);
|
||||||
|
const p = dirs.get(prev);
|
||||||
|
if (p) p.children.push(dirNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent = dirs.get(parentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.is_directory) {
|
||||||
|
let existing = dirs.get(f.path);
|
||||||
|
if (!existing) {
|
||||||
|
existing = { name, path: f.path, isDir: true, children: [], file: f };
|
||||||
|
dirs.set(f.path, existing);
|
||||||
|
if (parent) parent.children.push(existing);
|
||||||
|
} else {
|
||||||
|
existing.file = f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const node = { name, path: f.path, isDir: false, children: [], file: f };
|
||||||
|
if (parent) parent.children.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.children;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderTreeNodes(nodes, container, depth) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'editor-tree-row' + (node.path === this._activeFile ? ' active' : '');
|
||||||
|
row.style.paddingLeft = (12 + depth * 16) + 'px';
|
||||||
|
row.dataset.path = node.path;
|
||||||
|
|
||||||
|
if (node.isDir) {
|
||||||
|
const expanded = this._expandedDirs.has(node.path);
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="editor-tree-arrow ${expanded ? 'expanded' : ''}">${expanded ? '▾' : '▸'}</span>
|
||||||
|
<span class="editor-tree-icon">📁</span>
|
||||||
|
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
if (this._expandedDirs.has(node.path)) {
|
||||||
|
this._expandedDirs.delete(node.path);
|
||||||
|
} else {
|
||||||
|
this._expandedDirs.add(node.path);
|
||||||
|
}
|
||||||
|
this._renderFileTree();
|
||||||
|
});
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
if (expanded && node.children.length) {
|
||||||
|
this._renderTreeNodes(node.children, container, depth + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="editor-tree-icon">${this._fileIcon(node.name)}</span>
|
||||||
|
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
|
||||||
|
row.addEventListener('click', () => this._openFile(node.path));
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
row.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this._showFileContextMenu(e, node);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_fileIcon(name) {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase();
|
||||||
|
const icons = {
|
||||||
|
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||||
|
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||||
|
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||||
|
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||||
|
md: '📝', txt: '📄', svg: '🖼',
|
||||||
|
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||||
|
dockerfile: '🐳', makefile: '🔧',
|
||||||
|
mod: '📦', sum: '📦', lock: '🔒',
|
||||||
|
};
|
||||||
|
return icons[ext] || '📄';
|
||||||
|
},
|
||||||
|
|
||||||
|
_showFileContextMenu(e, node) {
|
||||||
|
// Simple context menu using CSS positioned div
|
||||||
|
const existing = document.querySelector('.editor-ctx-menu');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'editor-ctx-menu';
|
||||||
|
menu.style.left = e.clientX + 'px';
|
||||||
|
menu.style.top = e.clientY + 'px';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Open', action: () => this._openFile(node.path) },
|
||||||
|
{ label: 'Delete', action: () => this._deleteFile(node.path) },
|
||||||
|
];
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'editor-ctx-item';
|
||||||
|
row.textContent = item.label;
|
||||||
|
row.addEventListener('click', () => { menu.remove(); item.action(); });
|
||||||
|
menu.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
const dismiss = (ev) => {
|
||||||
|
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', dismiss), 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab Management ───────────────────────
|
||||||
|
|
||||||
|
async _openFile(path) {
|
||||||
|
if (this._openFiles.has(path)) {
|
||||||
|
this._activateTab(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch file content
|
||||||
|
let content = '';
|
||||||
|
try {
|
||||||
|
content = await API.readWorkspaceFile(this._wsId, path);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EditorMode] Failed to read file:', e);
|
||||||
|
if (typeof UI !== 'undefined') UI.toast(`Failed to open ${path}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = this._detectLanguage(path);
|
||||||
|
|
||||||
|
// Create tab
|
||||||
|
const tab = document.createElement('div');
|
||||||
|
tab.className = 'editor-tab';
|
||||||
|
tab.dataset.path = path;
|
||||||
|
tab.innerHTML = `
|
||||||
|
<span class="editor-tab-icon">${this._fileIcon(path.split('/').pop())}</span>
|
||||||
|
<span class="editor-tab-name">${this._esc(path.split('/').pop())}</span>
|
||||||
|
<span class="editor-tab-modified" style="display:none">●</span>
|
||||||
|
<button class="editor-tab-close" title="Close">✕</button>`;
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.editor-tab-close')) this._activateTab(path);
|
||||||
|
});
|
||||||
|
tab.querySelector('.editor-tab-close').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._closeFile(path);
|
||||||
|
});
|
||||||
|
this._tabBarEl.appendChild(tab);
|
||||||
|
|
||||||
|
// Create editor wrapper
|
||||||
|
const editorWrap = document.createElement('div');
|
||||||
|
editorWrap.className = 'editor-cm-wrap';
|
||||||
|
editorWrap.style.display = 'none';
|
||||||
|
this._editorAreaEl.appendChild(editorWrap);
|
||||||
|
|
||||||
|
// Create CM6 editor or textarea fallback
|
||||||
|
let editor = null;
|
||||||
|
if (window.CM?.codeEditor) {
|
||||||
|
editor = CM.codeEditor(editorWrap, {
|
||||||
|
language: lang,
|
||||||
|
value: content,
|
||||||
|
lineNumbers: true,
|
||||||
|
});
|
||||||
|
// Listen for changes to mark modified
|
||||||
|
if (editor.onUpdate) {
|
||||||
|
editor.onUpdate(() => this._markModified(path));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Textarea fallback
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.className = 'editor-textarea-fallback';
|
||||||
|
ta.value = content;
|
||||||
|
ta.addEventListener('input', () => this._markModified(path));
|
||||||
|
editorWrap.appendChild(ta);
|
||||||
|
editor = {
|
||||||
|
getValue: () => ta.value,
|
||||||
|
setValue: (v) => { ta.value = v; },
|
||||||
|
focus: () => ta.focus(),
|
||||||
|
destroy: () => {},
|
||||||
|
_textarea: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this._openFiles.set(path, {
|
||||||
|
tab, editorWrap, editor, content, modified: false, language: lang,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._activateTab(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
_activateTab(path) {
|
||||||
|
if (this._activeFile === path) return;
|
||||||
|
|
||||||
|
// Deactivate previous
|
||||||
|
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
||||||
|
const prev = this._openFiles.get(this._activeFile);
|
||||||
|
prev.tab.classList.remove('active');
|
||||||
|
prev.editorWrap.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeFile = path;
|
||||||
|
const f = this._openFiles.get(path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
f.tab.classList.add('active');
|
||||||
|
f.editorWrap.style.display = '';
|
||||||
|
this._welcomeEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Update status bar
|
||||||
|
this._updateStatusBar();
|
||||||
|
|
||||||
|
// Update file tree active state
|
||||||
|
this._els.fileTree.querySelectorAll('.editor-tree-row').forEach(row => {
|
||||||
|
row.classList.toggle('active', row.dataset.path === path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus editor
|
||||||
|
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _closeFile(path) {
|
||||||
|
const f = this._openFiles.get(path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
// Confirm if modified
|
||||||
|
if (f.modified) {
|
||||||
|
const save = await this._confirmClose(path);
|
||||||
|
if (save === null) return; // cancelled
|
||||||
|
if (save) await this._saveFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
f.tab.remove();
|
||||||
|
f.editorWrap.remove();
|
||||||
|
if (f.editor?.destroy) f.editor.destroy();
|
||||||
|
this._openFiles.delete(path);
|
||||||
|
|
||||||
|
// Activate next tab
|
||||||
|
if (this._activeFile === path) {
|
||||||
|
this._activeFile = null;
|
||||||
|
const remaining = Array.from(this._openFiles.keys());
|
||||||
|
if (remaining.length) {
|
||||||
|
this._activateTab(remaining[remaining.length - 1]);
|
||||||
|
} else {
|
||||||
|
this._welcomeEl.style.display = '';
|
||||||
|
this._updateStatusBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _confirmClose(path) {
|
||||||
|
if (typeof showConfirm === 'function') {
|
||||||
|
const r = await showConfirm(`Save changes to ${path.split('/').pop()}?`, {
|
||||||
|
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
|
||||||
|
});
|
||||||
|
return r; // true = save, false = discard
|
||||||
|
}
|
||||||
|
return window.confirm(`Save changes to ${path.split('/').pop()}?`);
|
||||||
|
},
|
||||||
|
|
||||||
|
_markModified(path) {
|
||||||
|
const f = this._openFiles.get(path);
|
||||||
|
if (!f || f.modified) return;
|
||||||
|
const currentVal = f.editor?.getValue?.() ?? '';
|
||||||
|
if (currentVal === f.content) return;
|
||||||
|
f.modified = true;
|
||||||
|
f.tab.querySelector('.editor-tab-modified').style.display = '';
|
||||||
|
f.tab.classList.add('modified');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Save ─────────────────────────────────
|
||||||
|
|
||||||
|
async _saveFile(path) {
|
||||||
|
const f = this._openFiles.get(path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
const content = f.editor?.getValue?.() ?? '';
|
||||||
|
try {
|
||||||
|
await API.writeWorkspaceFile(this._wsId, path, content);
|
||||||
|
f.content = content;
|
||||||
|
f.modified = false;
|
||||||
|
f.tab.querySelector('.editor-tab-modified').style.display = 'none';
|
||||||
|
f.tab.classList.remove('modified');
|
||||||
|
if (typeof UI !== 'undefined') UI.toast(`Saved ${path.split('/').pop()}`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EditorMode] Save failed:', e);
|
||||||
|
if (typeof UI !== 'undefined') UI.toast(`Failed to save: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _saveActiveFile() {
|
||||||
|
if (this._activeFile) await this._saveFile(this._activeFile);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _deleteFile(path) {
|
||||||
|
const ok = typeof showConfirm === 'function'
|
||||||
|
? await showConfirm(`Delete ${path}?`)
|
||||||
|
: window.confirm(`Delete ${path}?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.deleteWorkspaceFile(this._wsId, path);
|
||||||
|
// Close tab if open
|
||||||
|
if (this._openFiles.has(path)) {
|
||||||
|
const f = this._openFiles.get(path);
|
||||||
|
f.modified = false; // skip save prompt
|
||||||
|
await this._closeFile(path);
|
||||||
|
}
|
||||||
|
this._refreshFileTree();
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast(`Delete failed: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Language Detection ────────────────────
|
||||||
|
|
||||||
|
_detectLanguage(path) {
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase();
|
||||||
|
const map = {
|
||||||
|
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||||
|
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
||||||
|
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
|
||||||
|
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
|
||||||
|
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||||
|
md: 'markdown', mdx: 'markdown',
|
||||||
|
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
|
||||||
|
xml: 'xml', svg: 'xml',
|
||||||
|
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
|
||||||
|
java: 'java', kt: 'java',
|
||||||
|
php: 'php', pl: 'perl',
|
||||||
|
dockerfile: 'dockerfile',
|
||||||
|
};
|
||||||
|
return map[ext] || 'text';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Git ──────────────────────────────────
|
||||||
|
|
||||||
|
async _refreshGitBranch() {
|
||||||
|
if (!this._wsId) return;
|
||||||
|
try {
|
||||||
|
const resp = await API.getWorkspaceGitBranches(this._wsId);
|
||||||
|
this._gitBranch = resp.current || null;
|
||||||
|
this._updateBranchDisplay();
|
||||||
|
} catch (_) {
|
||||||
|
this._gitBranch = null;
|
||||||
|
this._updateBranchDisplay();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateBranchDisplay() {
|
||||||
|
const headerEl = this._els?.header?.querySelector('#editorBranch');
|
||||||
|
const statusEl = this._els?.footer?.querySelector('#editorStatusBranch');
|
||||||
|
const branchText = this._gitBranch ? `⎇ ${this._gitBranch}` : '';
|
||||||
|
|
||||||
|
if (headerEl) headerEl.textContent = branchText;
|
||||||
|
if (statusEl) statusEl.textContent = branchText;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Status Bar ───────────────────────────
|
||||||
|
|
||||||
|
_updateStatusBar() {
|
||||||
|
if (!this._els?.footer) return;
|
||||||
|
const fileEl = this._els.footer.querySelector('#editorStatusFile');
|
||||||
|
const langEl = this._els.footer.querySelector('#editorStatusLang');
|
||||||
|
const cursorEl = this._els.footer.querySelector('#editorStatusCursor');
|
||||||
|
|
||||||
|
if (this._activeFile) {
|
||||||
|
const f = this._openFiles.get(this._activeFile);
|
||||||
|
if (fileEl) fileEl.textContent = this._activeFile;
|
||||||
|
if (langEl) langEl.textContent = f?.language || '';
|
||||||
|
if (cursorEl) cursorEl.textContent = '';
|
||||||
|
} else {
|
||||||
|
if (fileEl) fileEl.textContent = 'No file open';
|
||||||
|
if (langEl) langEl.textContent = '';
|
||||||
|
if (cursorEl) cursorEl.textContent = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Quick Open (Ctrl+P) ──────────────────
|
||||||
|
|
||||||
|
_quickOpenEl: null,
|
||||||
|
|
||||||
|
_showQuickOpen() {
|
||||||
|
if (this._quickOpenEl) { this._quickOpenEl.remove(); this._quickOpenEl = null; return; }
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'editor-quick-open';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="editor-qo-dialog">
|
||||||
|
<input class="editor-qo-input" type="text" placeholder="Open file…" autocomplete="off">
|
||||||
|
<div class="editor-qo-results"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const input = overlay.querySelector('.editor-qo-input');
|
||||||
|
const results = overlay.querySelector('.editor-qo-results');
|
||||||
|
|
||||||
|
const allFiles = this._treeData.filter(f => !f.is_directory);
|
||||||
|
|
||||||
|
const render = (query) => {
|
||||||
|
results.innerHTML = '';
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const matches = q
|
||||||
|
? allFiles.filter(f => f.path.toLowerCase().includes(q)).slice(0, 20)
|
||||||
|
: allFiles.slice(0, 20);
|
||||||
|
|
||||||
|
for (const f of matches) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'editor-qo-row';
|
||||||
|
row.innerHTML = `<span class="editor-qo-icon">${this._fileIcon(f.path.split('/').pop())}</span> ${this._esc(f.path)}`;
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
overlay.remove();
|
||||||
|
this._quickOpenEl = null;
|
||||||
|
this._openFile(f.path);
|
||||||
|
});
|
||||||
|
results.appendChild(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', () => render(input.value));
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') { overlay.remove(); this._quickOpenEl = null; }
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const first = results.querySelector('.editor-qo-row');
|
||||||
|
if (first) first.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) { overlay.remove(); this._quickOpenEl = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this._quickOpenEl = overlay;
|
||||||
|
render('');
|
||||||
|
input.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Keyboard Shortcuts ───────────────────
|
||||||
|
|
||||||
|
_onKeyDown(e) {
|
||||||
|
if (!EditorMode._active) return;
|
||||||
|
|
||||||
|
// Ctrl/Cmd+S — save
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
EditorMode._saveActiveFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+P — quick open
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||||
|
e.preventDefault();
|
||||||
|
EditorMode._showQuickOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+W — close tab
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||||
|
if (EditorMode._activeFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
EditorMode._closeFile(EditorMode._activeFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Live Update on workspace.file.changed ─
|
||||||
|
|
||||||
|
_onFileChanged(ev) {
|
||||||
|
if (!EditorMode._active) return;
|
||||||
|
const { path } = ev || {};
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
// If the file is open, refresh its content
|
||||||
|
const f = EditorMode._openFiles.get(path);
|
||||||
|
if (f && !f.modified) {
|
||||||
|
API.readWorkspaceFile(EditorMode._wsId, path).then(content => {
|
||||||
|
if (f.editor?.setValue) f.editor.setValue(content);
|
||||||
|
f.content = content;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also refresh file tree
|
||||||
|
EditorMode._refreshFileTree();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────
|
||||||
|
|
||||||
|
_esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Global Init ──────────────────────────
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', this._onKeyDown);
|
||||||
|
|
||||||
|
// Listen for channel switches to check workspace availability
|
||||||
|
Events.on('chat.switched', () => this.check());
|
||||||
|
Events.on('chat.created', () => this.check());
|
||||||
|
|
||||||
|
// Listen for workspace file changes (from AI tool calls)
|
||||||
|
Events.on('workspace.file.changed', this._onFileChanged);
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
setTimeout(() => this.check(), 500);
|
||||||
|
|
||||||
|
console.log('[EditorMode] Initialized');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-init after DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => EditorMode.init());
|
||||||
|
} else {
|
||||||
|
EditorMode.init();
|
||||||
|
}
|
||||||
@@ -240,6 +240,12 @@ function showChatContextMenu(e, chatId) {
|
|||||||
<span class="ctx-icon">+</span> New project…
|
<span class="ctx-icon">+</span> New project…
|
||||||
</button>`;
|
</button>`;
|
||||||
|
|
||||||
|
// Workspace override (v0.21.5)
|
||||||
|
items += '<div class="ctx-divider"></div>';
|
||||||
|
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">📁</span> Set workspace…
|
||||||
|
</button>`;
|
||||||
|
|
||||||
menu.innerHTML = items;
|
menu.innerHTML = items;
|
||||||
|
|
||||||
// Position near cursor
|
// Position near cursor
|
||||||
@@ -392,6 +398,16 @@ function _registerProjectPanel() {
|
|||||||
<label class="project-panel-label">Notes</label>
|
<label class="project-panel-label">Notes</label>
|
||||||
<div id="projectNoteList" class="project-panel-list"></div>
|
<div id="projectNoteList" class="project-panel-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-panel-section">
|
||||||
|
<label class="project-panel-label">Workspace</label>
|
||||||
|
<div class="project-panel-ws-row" id="projectWsRow">
|
||||||
|
<select id="projectWsSelect" class="project-panel-select">
|
||||||
|
<option value="">None</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-small" id="projectWsCreate" title="Create workspace">+ New</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
|
||||||
|
</div>
|
||||||
<div class="project-panel-section project-panel-footer">
|
<div class="project-panel-section project-panel-footer">
|
||||||
<label class="project-panel-checkbox">
|
<label class="project-panel-checkbox">
|
||||||
<input type="checkbox" id="projectArchiveToggle">
|
<input type="checkbox" id="projectArchiveToggle">
|
||||||
@@ -413,6 +429,10 @@ function _registerProjectPanel() {
|
|||||||
// Wire archive toggle (v0.19.2)
|
// Wire archive toggle (v0.19.2)
|
||||||
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
|
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
|
||||||
|
|
||||||
|
// Wire workspace select + create (v0.21.5)
|
||||||
|
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
|
||||||
|
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
|
||||||
|
|
||||||
PanelRegistry.register('project', {
|
PanelRegistry.register('project', {
|
||||||
element: el,
|
element: el,
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
@@ -457,6 +477,9 @@ async function _loadProjectPanel(projectId) {
|
|||||||
|
|
||||||
// Load notes
|
// Load notes
|
||||||
await _renderProjectNotes(projectId);
|
await _renderProjectNotes(projectId);
|
||||||
|
|
||||||
|
// Load workspace picker (v0.21.5)
|
||||||
|
await _renderProjectWorkspace(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _saveProjectPrompt() {
|
async function _saveProjectPrompt() {
|
||||||
@@ -643,6 +666,143 @@ async function _toggleProjectArchive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Workspace picker (v0.21.5) ───────────────
|
||||||
|
|
||||||
|
async function _renderProjectWorkspace(projectId) {
|
||||||
|
const sel = document.getElementById('projectWsSelect');
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
// Fetch current project state + available workspaces in parallel
|
||||||
|
const [proj, workspaces] = await Promise.all([
|
||||||
|
API.getProject(projectId).catch(() => null),
|
||||||
|
API.listWorkspaces().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentWsId = proj?.workspace_id || '';
|
||||||
|
const wsList = Array.isArray(workspaces) ? workspaces : (workspaces?.data || []);
|
||||||
|
|
||||||
|
sel.innerHTML = '<option value="">None</option>';
|
||||||
|
for (const ws of wsList) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ws.id;
|
||||||
|
opt.textContent = ws.name || ws.id.slice(0, 8);
|
||||||
|
if (ws.id === currentWsId) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveProjectWorkspace() {
|
||||||
|
if (!_projectPanelId) return;
|
||||||
|
const wsId = document.getElementById('projectWsSelect').value;
|
||||||
|
try {
|
||||||
|
await API.updateProject(_projectPanelId, { workspace_id: wsId || null });
|
||||||
|
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||||||
|
// Trigger editor surface check
|
||||||
|
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to update workspace', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _createProjectWorkspace() {
|
||||||
|
if (!_projectPanelId) return;
|
||||||
|
const proj = App.projects.find(p => p.id === _projectPanelId);
|
||||||
|
const name = prompt('Workspace name:', (proj?.name || 'Project') + ' Workspace');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = await API.createWorkspace({
|
||||||
|
name: name.trim(),
|
||||||
|
owner_type: 'user',
|
||||||
|
owner_id: API.user?.id || '',
|
||||||
|
});
|
||||||
|
// Auto-bind to project
|
||||||
|
await API.updateProject(_projectPanelId, { workspace_id: ws.id });
|
||||||
|
UI.toast('Workspace created and linked', 'success');
|
||||||
|
// Refresh the picker
|
||||||
|
await _renderProjectWorkspace(_projectPanelId);
|
||||||
|
// Trigger editor surface check
|
||||||
|
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to create workspace: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel workspace picker (v0.21.5) ───────
|
||||||
|
|
||||||
|
async function showWorkspacePicker(chatId) {
|
||||||
|
let workspaces = [];
|
||||||
|
try {
|
||||||
|
const resp = await API.listWorkspaces();
|
||||||
|
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Get current channel workspace
|
||||||
|
let currentWsId = '';
|
||||||
|
try {
|
||||||
|
const ch = await API.getChannel(chatId);
|
||||||
|
currentWsId = ch.workspace_id || '';
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Build options
|
||||||
|
const options = [{ value: '', label: 'None (inherit from project)' }];
|
||||||
|
for (const ws of workspaces) {
|
||||||
|
options.push({ value: ws.id, label: ws.name || ws.id.slice(0, 8) });
|
||||||
|
}
|
||||||
|
options.push({ value: '__new__', label: '+ Create new workspace…' });
|
||||||
|
|
||||||
|
// Use simple select via popup
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'editor-quick-open'; // reuse overlay style
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="editor-qo-dialog" style="width:340px">
|
||||||
|
<div style="padding:10px 14px; font-weight:600; border-bottom:1px solid var(--border)">Set Workspace</div>
|
||||||
|
<div class="editor-qo-results" id="wsPickerList"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const list = overlay.querySelector('#wsPickerList');
|
||||||
|
for (const opt of options) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'editor-qo-row' + (opt.value === currentWsId ? ' active' : '');
|
||||||
|
row.style.fontWeight = opt.value === currentWsId ? '600' : '';
|
||||||
|
row.textContent = opt.label;
|
||||||
|
row.addEventListener('click', async () => {
|
||||||
|
overlay.remove();
|
||||||
|
if (opt.value === '__new__') {
|
||||||
|
const name = prompt('Workspace name:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
const ws = await API.createWorkspace({
|
||||||
|
name: name.trim(),
|
||||||
|
owner_type: 'user',
|
||||||
|
owner_id: API.user?.id || '',
|
||||||
|
});
|
||||||
|
await API.updateChannel(chatId, { workspace_id: ws.id });
|
||||||
|
UI.toast('Workspace created and linked', 'success');
|
||||||
|
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await API.updateChannel(chatId, { workspace_id: opt.value || '' });
|
||||||
|
UI.toast(opt.value ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||||||
|
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// CHANNEL REORDER WITHIN PROJECT (v0.19.2)
|
// CHANNEL REORDER WITHIN PROJECT (v0.19.2)
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -167,6 +167,28 @@ const Surfaces = {
|
|||||||
return this._registry.size > 1;
|
return this._registry.size > 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a saved DocumentFragment for a specific surface + region.
|
||||||
|
* Used by surfaces like editor-mode that want to embed chat DOM
|
||||||
|
* inside their own layout.
|
||||||
|
* @param {string} surfaceId — the surface that owns the saved DOM
|
||||||
|
* @param {string} regionId — the region name
|
||||||
|
* @returns {DocumentFragment|null}
|
||||||
|
*/
|
||||||
|
getSavedFragment(surfaceId, regionId) {
|
||||||
|
const key = `${surfaceId}::${regionId}`;
|
||||||
|
return this._saved.get(key) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a DocumentFragment back into the saved store.
|
||||||
|
* Used during deactivation to return borrowed DOM.
|
||||||
|
*/
|
||||||
|
putSavedFragment(surfaceId, regionId, frag) {
|
||||||
|
const key = `${surfaceId}::${regionId}`;
|
||||||
|
this._saved.set(key, frag);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Region Management ────────────────────
|
// ── Region Management ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user