diff --git a/.gitignore b/.gitignore index da9257abfa..09a8da1989 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,16 @@ Thumbs.db /test/pro-test-suite.js /src/extensionsIntegrated/phoenix-pro +# ignore node_modules inside phoenix-builder-mcp +/phoenix-builder-mcp/node_modules + +# ignore MCP server runtime files +/phoenix-builder-mcp/.mcp-server.pid + +# ignore chrome extension build artifacts +/phoenix-builder-mcp/chrome_extension/build/ +/phoenix-builder-mcp/chrome_extension/*.zip + # ignore node_modules inside src /src/node_modules /src-node/node_modules diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..a2dc6bede8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "phoenix-builder": { + "command": "node", + "args": ["phoenix-builder-mcp/index.js"], + "env": { + "PHOENIX_DESKTOP_PATH": "../phoenix-desktop" + } + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..cf905ef066 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# Claude Code Instructions + +## Git Commits +- Use Conventional Commits format: `type(scope): description` (e.g. `fix: ...`, `feat: ...`, `chore: ...`). +- Keep commit subject lines concise; use the body for detail. +- Never include `Co-Authored-By` lines in commit messages. + +## Code Style +- 4-space indentation, never tabs. +- Always use semicolons. +- Brace style: (`if (x) {`), single-line blocks allowed. +- Always use curly braces for `if`/`else`/`for`/`while`. +- No trailing whitespace. +- Use `const` and `let` instead of `var`. diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index 0e2fef4988..e5b864be48 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -734,6 +734,12 @@ Opens support resources ## HELP\_GET\_PRO Opens Phoenix Pro page +**Kind**: global variable + + +## HELP\_CANCEL\_TRIAL +Cancels Phoenix Pro trial + **Kind**: global variable diff --git a/docs/API-Reference/view/MainViewFactory.md b/docs/API-Reference/view/MainViewFactory.md index 51e1db4793..5678cf93d9 100644 --- a/docs/API-Reference/view/MainViewFactory.md +++ b/docs/API-Reference/view/MainViewFactory.md @@ -3,39 +3,80 @@ const MainViewFactory = brackets.getModule("view/MainViewFactory") ``` - + -## \_ -MainViewFactory is a singleton for managing view factories. Registering a view factory: ```js registerViewFactory({ canOpenFile: function (fullPath) { return (fullPath.slice(-4) === ".ico"); }, openFile: function(file, pane) { return createIconView(file, pane); } }); ``` The openFile method is used to open the file and construct a view of it. Implementation should add the view to the pane ```js function createIconView(file, pane) { // IconView will construct its DOM and append // it to pane.$el var view = new IconView(file, pane.$el); // Then tell the pane to add it to // its view map and show it pane.addView(view, true); return new $.Deferred().resolve().promise(); } ``` Factories should only create 1 view of a file per pane. Brackets currently only supports 1 view of a file open at a given time but that may change to allow the same file open in more than 1 pane. Therefore Factories can do a simple check to see if a view already exists and show it before creating a new one: ```js var view = pane.getViewForPath(file.fullPath); if (view) { pane.showView(view); } else { return createIconView(file, pane); } ``` +## view/MainViewFactory +MainViewFactory is a singleton for managing view factories. -**Kind**: global variable - +Registering a view factory: +```js + registerViewFactory({ + canOpenFile: function (fullPath) { + return (fullPath.slice(-4) === ".ico"); + }, + openFile: function(file, pane) { + return createIconView(file, pane); + } + }); +``` + The openFile method is used to open the file and construct + a view of it. Implementation should add the view to the pane +```js + function createIconView(file, pane) { + // IconView will construct its DOM and append + // it to pane.$el + var view = new IconView(file, pane.$el); + // Then tell the pane to add it to + // its view map and show it + pane.addView(view, true); + return new $.Deferred().resolve().promise(); + } +``` + Factories should only create 1 view of a file per pane. Brackets currently only supports 1 view of + a file open at a given time but that may change to allow the same file open in more than 1 pane. Therefore + Factories can do a simple check to see if a view already exists and show it before creating a new one: +```js + var view = pane.getViewForPath(file.fullPath); + if (view) { + pane.showView(view); + } else { + return createIconView(file, pane); + } +``` + + +* [view/MainViewFactory](#module_view/MainViewFactory) + * [.registerViewFactory(factory)](#module_view/MainViewFactory..registerViewFactory) + * [.findSuitableFactoryForPath(fullPath)](#module_view/MainViewFactory..findSuitableFactoryForPath) ⇒ Factory + * [.Factory](#module_view/MainViewFactory..Factory) : Object + + -## registerViewFactory(factory) +### view/MainViewFactory.registerViewFactory(factory) Registers a view factory -**Kind**: global function +**Kind**: inner method of [view/MainViewFactory](#module_view/MainViewFactory) | Param | Type | Description | | --- | --- | --- | -| factory | [Factory](#Factory) | The view factory to register. | +| factory | Factory | The view factory to register. | - + -## findSuitableFactoryForPath(fullPath) ⇒ [Factory](#Factory) +### view/MainViewFactory.findSuitableFactoryForPath(fullPath) ⇒ Factory Finds a factory that can open the specified file -**Kind**: global function -**Returns**: [Factory](#Factory) - A factory that can create a view for the path or undefined if there isn't one. +**Kind**: inner method of [view/MainViewFactory](#module_view/MainViewFactory) +**Returns**: Factory - A factory that can create a view for the path or undefined if there isn't one. | Param | Type | Description | | --- | --- | --- | | fullPath | string | The file to open. | - + -## Factory : Object -**Kind**: global typedef +### view/MainViewFactory.Factory : Object +**Kind**: inner typedef of [view/MainViewFactory](#module_view/MainViewFactory) **Properties** | Name | Type | Description | diff --git a/docs/API-Reference/view/MainViewManager.md b/docs/API-Reference/view/MainViewManager.md index f4a71dde3e..0d38ca3362 100644 --- a/docs/API-Reference/view/MainViewManager.md +++ b/docs/API-Reference/view/MainViewManager.md @@ -3,9 +3,9 @@ const MainViewManager = brackets.getModule("view/MainViewManager") ``` - + -## \_ +## view/MainViewManager MainViewManager manages the arrangement of all open panes as well as provides the controller logic behind all views in the MainView (e.g. ensuring that a file doesn't appear in 2 lists) @@ -56,226 +56,261 @@ This module dispatches several events: To listen for events, do something like this: (see EventDispatcher for details on this pattern) `MainViewManager.on("eventname", handler);` -**Kind**: global variable - -## EVENT\_CURRENT\_FILE\_CHANGE : string +* [view/MainViewManager](#module_view/MainViewManager) + * [.EVENT_CURRENT_FILE_CHANGE](#module_view/MainViewManager..EVENT_CURRENT_FILE_CHANGE) : string + * [.ALL_PANES](#module_view/MainViewManager..ALL_PANES) + * [.ACTIVE_PANE](#module_view/MainViewManager..ACTIVE_PANE) + * [.isExclusiveToPane(File)](#module_view/MainViewManager..isExclusiveToPane) ⇒ Object + * [.getActivePaneId()](#module_view/MainViewManager..getActivePaneId) ⇒ string + * [.focusActivePane()](#module_view/MainViewManager..focusActivePane) + * [.setActivePaneId(paneId)](#module_view/MainViewManager..setActivePaneId) + * [.getCurrentlyViewedFile(paneId)](#module_view/MainViewManager..getCurrentlyViewedFile) ⇒ File + * [.getCurrentlyViewedEditor(paneId)](#module_view/MainViewManager..getCurrentlyViewedEditor) ⇒ Editor + * [.getAllViewedEditors()](#module_view/MainViewManager..getAllViewedEditors) ⇒ Object + * [.getCurrentlyViewedPath(paneId)](#module_view/MainViewManager..getCurrentlyViewedPath) ⇒ string + * [.cacheScrollState(paneId)](#module_view/MainViewManager..cacheScrollState) + * [.restoreAdjustedScrollState(paneId, heightDelta)](#module_view/MainViewManager..restoreAdjustedScrollState) + * [.getWorkingSet(paneId)](#module_view/MainViewManager..getWorkingSet) ⇒ Array.<File> + * [.getAllOpenFiles()](#module_view/MainViewManager..getAllOpenFiles) ⇒ array.<File> + * [.getPaneIdList()](#module_view/MainViewManager..getPaneIdList) ⇒ array.<string> + * [.getWorkingSetSize(paneId)](#module_view/MainViewManager..getWorkingSetSize) ⇒ number + * [.getPaneTitle(paneId)](#module_view/MainViewManager..getPaneTitle) ⇒ string + * [.getPaneCount()](#module_view/MainViewManager..getPaneCount) ⇒ number + * [.findInAllWorkingSets(fullPath)](#module_view/MainViewManager..findInAllWorkingSets) ⇒ Object + * [.findInOpenPane(fullPath)](#module_view/MainViewManager..findInOpenPane) ⇒ Object + * [.findInWorkingSet(paneId, fullPath)](#module_view/MainViewManager..findInWorkingSet) ⇒ number + * [.findInWorkingSetByAddedOrder(paneId, fullPath)](#module_view/MainViewManager..findInWorkingSetByAddedOrder) ⇒ number + * [.findInWorkingSetByMRUOrder(paneId, fullPath)](#module_view/MainViewManager..findInWorkingSetByMRUOrder) ⇒ number + * [.addToWorkingSet(paneId, file, [index], [forceRedraw])](#module_view/MainViewManager..addToWorkingSet) + * [.addListToWorkingSet(paneId, fileList)](#module_view/MainViewManager..addListToWorkingSet) + * [.switchPaneFocus()](#module_view/MainViewManager..switchPaneFocus) + * [.traverseToNextViewByMRU(direction)](#module_view/MainViewManager..traverseToNextViewByMRU) ⇒ Object + * [.traverseToNextViewInListOrder(direction)](#module_view/MainViewManager..traverseToNextViewInListOrder) ⇒ Object + * [.beginTraversal()](#module_view/MainViewManager..beginTraversal) + * [.endTraversal()](#module_view/MainViewManager..endTraversal) + * [.setLayoutScheme(rows, columns)](#module_view/MainViewManager..setLayoutScheme) + * [.getLayoutScheme()](#module_view/MainViewManager..getLayoutScheme) ⇒ Object + + + +### view/MainViewManager.EVENT\_CURRENT\_FILE\_CHANGE : string Event current file change -**Kind**: global constant - +**Kind**: inner constant of [view/MainViewManager](#module_view/MainViewManager) + -## ALL\_PANES +### view/MainViewManager.ALL\_PANES Special paneId shortcut that can be used to specify that all panes should be targeted by the API. Not all APIs support this constnant. Check the API documentation before use. -**Kind**: global constant - +**Kind**: inner constant of [view/MainViewManager](#module_view/MainViewManager) + -## ACTIVE\_PANE +### view/MainViewManager.ACTIVE\_PANE Special paneId shortcut that can be used to specify that the API should target the focused pane only. All APIs support this shortcut. -**Kind**: global constant - +**Kind**: inner constant of [view/MainViewManager](#module_view/MainViewManager) + -## isExclusiveToPane(File) ⇒ Object +### view/MainViewManager.isExclusiveToPane(File) ⇒ Object Checks whether a file is listed exclusively in the provided pane -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | File | File | the file | - + -## getActivePaneId() ⇒ string +### view/MainViewManager.getActivePaneId() ⇒ string Retrieves the currently active Pane Id -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: string - Active Pane's ID. - + -## focusActivePane() +### view/MainViewManager.focusActivePane() Focuses the current pane. If the current pane has a current view, then the pane will focus the view. -**Kind**: global function - +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + -## setActivePaneId(paneId) +### view/MainViewManager.setActivePaneId(paneId) Switch active pane to the specified pane id (or ACTIVE_PANE/ALL_PANES, in which case this call does nothing). -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | paneId | string | the id of the pane to activate | - + -## getCurrentlyViewedFile(paneId) ⇒ File +### view/MainViewManager.getCurrentlyViewedFile(paneId) ⇒ File Retrieves the currently viewed file of the specified paneId -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: File - File object of the currently viewed file, or null if there isn't one or there's no such pane | Param | Type | Description | | --- | --- | --- | | paneId | string | the id of the pane in which to retrieve the currently viewed file | - + -## getCurrentlyViewedEditor(paneId) ⇒ Editor +### view/MainViewManager.getCurrentlyViewedEditor(paneId) ⇒ Editor Retrieves the currently viewed editor of the specified paneId -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Editor - currently editor, or null if there isn't one or there's no such pane | Param | Type | Description | | --- | --- | --- | | paneId | string | the id of the pane in which to retrieve the currently viewed editor | - + -## getAllViewedEditors() ⇒ Object +### view/MainViewManager.getAllViewedEditors() ⇒ Object Gets an array of editors open in panes with their pane IDs. Can return an empty array if no editors are open. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - An array of objects, each containing an editor and its corresponding pane ID. - + -## getCurrentlyViewedPath(paneId) ⇒ string +### view/MainViewManager.getCurrentlyViewedPath(paneId) ⇒ string Retrieves the currently viewed path of the pane specified by paneId -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: string - the path of the currently viewed file or null if there isn't one | Param | Type | Description | | --- | --- | --- | | paneId | string | the id of the pane in which to retrieve the currently viewed path | - + -## cacheScrollState(paneId) +### view/MainViewManager.cacheScrollState(paneId) Caches the specified pane's current scroll state If there was already cached state for the specified pane, it is discarded and overwritten -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | paneId | string | id of the pane in which to cache the scroll state, ALL_PANES or ACTIVE_PANE | - + -## restoreAdjustedScrollState(paneId, heightDelta) +### view/MainViewManager.restoreAdjustedScrollState(paneId, heightDelta) Restores the scroll state from cache and applies the heightDelta The view implementation is responsible for applying or ignoring the heightDelta. This is used primarily when a modal bar opens to keep the editor from scrolling the current page out of view in order to maintain the appearance. The state is removed from the cache after calling this function. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | paneId | string | id of the pane in which to adjust the scroll state, ALL_PANES or ACTIVE_PANE | | heightDelta | number | delta H to apply to the scroll state | - + -## getWorkingSet(paneId) ⇒ Array.<File> +### view/MainViewManager.getWorkingSet(paneId) ⇒ Array.<File> Retrieves the WorkingSet for the given paneId not including temporary views -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | paneId | string | id of the pane in which to get the view list, ALL_PANES or ACTIVE_PANE | - + -## getAllOpenFiles() ⇒ array.<File> +### view/MainViewManager.getAllOpenFiles() ⇒ array.<File> Retrieves the list of all open files including temporary views -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: array.<File> - the list of all open files in all open panes - + -## getPaneIdList() ⇒ array.<string> +### view/MainViewManager.getPaneIdList() ⇒ array.<string> Retrieves the list of all open pane ids -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: array.<string> - the list of all open panes - + -## getWorkingSetSize(paneId) ⇒ number +### view/MainViewManager.getWorkingSetSize(paneId) ⇒ number Retrieves the size of the selected pane's view list -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: number - the number of items in the specified pane | Param | Type | Description | | --- | --- | --- | | paneId | string | id of the pane in which to get the workingset size. Can use `ALL_PANES` or `ACTIVE_PANE` | - + -## getPaneTitle(paneId) ⇒ string +### view/MainViewManager.getPaneTitle(paneId) ⇒ string Retrieves the title to display in the workingset view -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: string - title | Param | Type | Description | | --- | --- | --- | | paneId | string | id of the pane in which to get the title | - + -## getPaneCount() ⇒ number +### view/MainViewManager.getPaneCount() ⇒ number Retrieves the number of panes -**Kind**: global function - +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + -## findInAllWorkingSets(fullPath) ⇒ Object +### view/MainViewManager.findInAllWorkingSets(fullPath) ⇒ Object Finds all instances of the specified file in all working sets. If there is a temporary view of the file, it is not part of the result set -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - an array of paneId/index records | Param | Type | Description | | --- | --- | --- | | fullPath | string | path of the file to find views of | - + -## findInOpenPane(fullPath) ⇒ Object -Returns the pane IDs and editors (if present) of the given file in any open and viewable pane. -If the same file is open in multiple panes, all matching panes will be returned. +### view/MainViewManager.findInOpenPane(fullPath) ⇒ Object +Returns the pane IDs and editors (if present) of the given file in any open and viewable pane. +If the same file is open in multiple panes, all matching panes will be returned. If not found in any panes, an empty array will be returned. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - An array of objects, each containing the pane ID and the corresponding editor, if present. | Param | Type | Description | | --- | --- | --- | | fullPath | string | The full path of the file to search for. | - + -## findInWorkingSet(paneId, fullPath) ⇒ number +### view/MainViewManager.findInWorkingSet(paneId, fullPath) ⇒ number Gets the index of the file matching fullPath in the workingset -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: number - index, -1 if not found. | Param | Type | Description | @@ -283,12 +318,12 @@ Gets the index of the file matching fullPath in the workingset | paneId | string | id of the pane in which to search or ALL_PANES or ACTIVE_PANE | | fullPath | string | full path of the file to search for | - + -## findInWorkingSetByAddedOrder(paneId, fullPath) ⇒ number +### view/MainViewManager.findInWorkingSetByAddedOrder(paneId, fullPath) ⇒ number Gets the index of the file matching fullPath in the added order workingset -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: number - index, -1 if not found. | Param | Type | Description | @@ -296,12 +331,12 @@ Gets the index of the file matching fullPath in the added order workingset | paneId | string | id of the pane in which to search or ALL_PANES or ACTIVE_PANE | | fullPath | string | full path of the file to search for | - + -## findInWorkingSetByMRUOrder(paneId, fullPath) ⇒ number +### view/MainViewManager.findInWorkingSetByMRUOrder(paneId, fullPath) ⇒ number Gets the index of the file matching fullPath in the MRU order workingset -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: number - index, -1 if not found. | Param | Type | Description | @@ -309,9 +344,9 @@ Gets the index of the file matching fullPath in the MRU order workingset | paneId | string | id of the pane in which to search or ALL_PANES or ACTIVE_PANE | | fullPath | string | full path of the file to search for | - + -## addToWorkingSet(paneId, file, [index], [forceRedraw]) +### view/MainViewManager.addToWorkingSet(paneId, file, [index], [forceRedraw]) Adds the given file to the end of the workingset, if it is not already there. This API does not create a view of the file, it just adds it to the working set Views of files in the working set are persisted and are not destroyed until the user @@ -319,7 +354,7 @@ Views of files in the working set are persisted and are not destroyed until the made the current view. If a File is already opened then the file is just made current and its view is shown. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | @@ -328,30 +363,30 @@ Views of files in the working set are persisted and are not destroyed until the | [index] | number | Position to add to list (defaults to last); -1 is ignored | | [forceRedraw] | boolean | If true, a workingset change notification is always sent (useful if suppressRedraw was used with removeView() earlier) | - + -## addListToWorkingSet(paneId, fileList) +### view/MainViewManager.addListToWorkingSet(paneId, fileList) Adds the given file list to the end of the workingset. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) | Param | Type | Description | | --- | --- | --- | | paneId | string | The id of the pane in which to add the file object to or ACTIVE_PANE | | fileList | Array.<File> | Array of files to add to the pane | - + -## switchPaneFocus() +### view/MainViewManager.switchPaneFocus() Switch between panes -**Kind**: global function - +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + -## traverseToNextViewByMRU(direction) ⇒ Object +### view/MainViewManager.traverseToNextViewByMRU(direction) ⇒ Object Get the next or previous file in the MRU list. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - The File object of the next item in the traversal order or null if there aren't any files to traverse. May return current file if there are no other files to traverse. @@ -359,12 +394,12 @@ Get the next or previous file in the MRU list. | --- | --- | --- | | direction | number | Must be 1 or -1 to traverse forward or backward | - + -## traverseToNextViewInListOrder(direction) ⇒ Object +### view/MainViewManager.traverseToNextViewInListOrder(direction) ⇒ Object Get the next or previous file in list order. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - The File object of the next item in the traversal order or null if there aren't any files to traverse. May return current file if there are no other files to traverse. @@ -372,26 +407,26 @@ Get the next or previous file in list order. | --- | --- | --- | | direction | number | Must be 1 or -1 to traverse forward or backward | - + -## beginTraversal() +### view/MainViewManager.beginTraversal() Indicates that traversal has begun. Can be called any number of times. -**Kind**: global function - +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + -## endTraversal() +### view/MainViewManager.endTraversal() Un-freezes the MRU list after one or more beginTraversal() calls. Whatever file is current is bumped to the front of the MRU list. -**Kind**: global function - +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) + -## setLayoutScheme(rows, columns) +### view/MainViewManager.setLayoutScheme(rows, columns) Changes the layout scheme -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Summay**: Rows or Columns may be 1 or 2 but both cannot be 2. 1x2, 2x1 or 1x1 are the legal values | Param | Type | Description | @@ -399,10 +434,10 @@ Changes the layout scheme | rows | number | (may be 1 or 2) | | columns | number | (may be 1 or 2) | - + -## getLayoutScheme() ⇒ Object +### view/MainViewManager.getLayoutScheme() ⇒ Object Retrieves the current layout scheme. -**Kind**: global function +**Kind**: inner method of [view/MainViewManager](#module_view/MainViewManager) **Returns**: Object - - An object containing the number of rows and columns in the layout. diff --git a/docs/API-Reference/view/Pane.md b/docs/API-Reference/view/Pane.md index 399b3df8e7..2ca1cdab71 100644 --- a/docs/API-Reference/view/Pane.md +++ b/docs/API-Reference/view/Pane.md @@ -3,59 +3,235 @@ const Pane = brackets.getModule("view/Pane") ``` - + -## Pane -**Kind**: global class +## view/Pane +Pane objects host views of files, editors, etc... Clients cannot access +Pane objects directly. Instead the implementation is protected by the +MainViewManager -- however View Factories are given a Pane object which +they can use to add views. References to Pane objects should not be kept +as they may be destroyed and removed from the DOM. + +To get a custom view, there are two components: + + 1) A View Factory + 2) A View Object + +View objects are anonymous object that have a particular interface. + +Views can be added to a pane but do not have to exist in the Pane object's view list. +Such views are "temporary views". Temporary views are not serialized with the Pane state +or reconstituted when the pane is serialized from disk. They are destroyed at the earliest +opportunity. + +Temporary views are added by calling `Pane.showView()` and passing it the view object. The view +will be destroyed when the next view is shown, the pane is mereged with another pane or the "Close All" +command is exectuted on the Pane. Temporary Editor Views do not contain any modifications and are +added to the workingset (and are no longer tempoary views) once the document has been modified. They +will remain in the working set until closed from that point on. + +Views that have a longer life span are added by calling addView to associate the view with a +filename in the _views object. These views are not destroyed until they are removed from the pane +by calling one of the following: removeView, removeViews, or _reset + +Pane Object Events: + + - viewListChange - Whenever there is a file change to a file in the working set. These 2 events: `DocumentManager.pathRemove` + and `DocumentManager.fileNameChange` will cause a `viewListChange` event so the WorkingSetView can update. + + - currentViewChange - Whenever the current view changes. + (e, newView:View, oldView:View) + + - viewDestroy - Whenever a view has been destroyed + (e, view:View) + +View Interface: + +The view is an anonymous object which has the following method signatures. see ImageViewer for an example or the sample +provided with Brackets `src/extensions/samples/BracketsConfigCentral` + +```js + { + $el:jQuery + getFile: function ():!File + updateLayout: function(forceRefresh:boolean) + destroy: function() + getScrollPos: function():*= + adjustScrollPos: function(state:Object=, heightDelta:number)= + notifyContainerChange: function()= + notifyVisibilityChange: function(boolean)= + focus:function()= + } +``` +When views are created they can be added to the pane by calling `pane.addView()`. +Views can be created and parented by attaching directly to `pane.$el` + + this._codeMirror = new CodeMirror(pane.$el, ...) + +Factories can create a view that's initially hidden by calling `pane.addView(view)` and passing `false` for the show parameter. +Hidden views can be later shown by calling `pane.showView(view)` + +`$el:jQuery!` + + property that stores the jQuery wrapped DOM element of the view. All views must have one so pane objects can manipulate the DOM + element when necessary (e.g. `showView`, `_reparent`, etc...) + +`getFile():File!` + + Called throughout the life of a View when the current file is queried by the system. + +`updateLayout(forceRefresh:boolean)` + + Called to notify the view that it should be resized to fit its parent container. This may be called several times + or only once. Views can ignore the `forceRefresh` flag. It is used for editor views to force a relayout of the editor + which probably isn't necessary for most views. Views should implement their html to be dynamic and not rely on this + function to be called whenever possible. + +`destroy()` + + Views must implement a destroy method to remove their DOM element at the very least. There is no default + implementation and views are hidden before this method is called. The Pane object doesn't make assumptions + about when it is safe to remove a node. In some instances other cleanup must take place before a the DOM + node is destroyed so the implementation details are left to the view. + + Views can implement a simple destroy by calling + + this.$el.remove() + + These members are optional and need not be implemented by Views + + getScrollPos() + adjustScrollPos() + + The system at various times will want to save and restore a view's scroll position. The data returned by `getScrollPos()` + is specific to the view and will be passed back to `adjustScrollPos()` when the scroll position needs to be restored. + + When Modal Bars are invoked, the system calls `getScrollPos()` so that the current scroll psotion of all visible Views can be cached. + That cached scroll position is later passed to `adjustScrollPos()` along with a height delta. The height delta is used to + scroll the view so that it doesn't appear to have "jumped" when invoking the Modal Bar. + + Height delta will be a positive when the Modal Bar is being shown and negative number when the Modal Bar is being hidden. + + `getViewState()` is another optional member that is used to cache a view's state when hiding or destroying a view or closing the project. + The data returned by this member is stored in `ViewStateManager` and is saved with the project. + + Views or View Factories are responsible for restoring the view state when the view of that file is created by recalling the cached state + + var view = createIconView(file, pane); + view.restoreViewState(ViewStateManager.getViewState(file.fullPath)); + + Notifications + The following optional methods receive notifications from the Pane object when certain events take place which affect the view: + +`notifyContainerChange()` + + Optional Notification callback called when the container changes. The view can perform any synchronization or state update + it needs to do when its parent container changes. + +`notifyVisiblityChange()` + + Optional Notification callback called when the view's vsibility changes. The view can perform any synchronization or + state update it needs to do when its visiblity state changes. + + +* [view/Pane](#module_view/Pane) + * [.Pane](#module_view/Pane..Pane) + * [new Pane(id, $container)](#new_module_view/Pane..Pane_new) + * [.id](#module_view/Pane..Pane+id) : string + * [.$container](#module_view/Pane..Pane+$container) : JQuery + * [.$el](#module_view/Pane..Pane+$el) : JQuery + * [.$header](#module_view/Pane..Pane+$header) : JQuery + * [.$headerText](#module_view/Pane..Pane+$headerText) : JQuery + * [.$headerFlipViewBtn](#module_view/Pane..Pane+$headerFlipViewBtn) : JQuery + * [.$headerCloseBtn](#module_view/Pane..Pane+$headerCloseBtn) : JQuery + * [.$content](#module_view/Pane..Pane+$content) : JQuery + * [.ITEM_NOT_FOUND](#module_view/Pane..Pane+ITEM_NOT_FOUND) + * [.ITEM_FOUND_NO_SORT](#module_view/Pane..Pane+ITEM_FOUND_NO_SORT) + * [.ITEM_FOUND_NEEDS_SORT](#module_view/Pane..Pane+ITEM_FOUND_NEEDS_SORT) + * [.mergeFrom(other)](#module_view/Pane..Pane+mergeFrom) + * [.destroy()](#module_view/Pane..Pane+destroy) + * [.getViewList()](#module_view/Pane..Pane+getViewList) ⇒ Array.<File> + * [.getViewListSize()](#module_view/Pane..Pane+getViewListSize) ⇒ number + * [.findInViewList(fullPath)](#module_view/Pane..Pane+findInViewList) ⇒ number + * [.findInViewListAddedOrder(fullPath)](#module_view/Pane..Pane+findInViewListAddedOrder) ⇒ number + * [.findInViewListMRUOrder(fullPath)](#module_view/Pane..Pane+findInViewListMRUOrder) ⇒ number + * [.reorderItem(file, [index], [force])](#module_view/Pane..Pane+reorderItem) ⇒ number + * [.addToViewList(file, [index])](#module_view/Pane..Pane+addToViewList) ⇒ number + * [.addListToViewList(fileList)](#module_view/Pane..Pane+addListToViewList) ⇒ Array.<File> + * [.makeViewMostRecent(file)](#module_view/Pane..Pane+makeViewMostRecent) + * [.sortViewList(compareFn)](#module_view/Pane..Pane+sortViewList) + * [.swapViewListIndexes(index1, index2)](#module_view/Pane..Pane+swapViewListIndexes) ⇒ boolean + * [.traverseViewListByMRU(direction, [current])](#module_view/Pane..Pane+traverseViewListByMRU) ⇒ File + * [.showInterstitial(show)](#module_view/Pane..Pane+showInterstitial) + * [.getViewForPath(path)](#module_view/Pane..Pane+getViewForPath) ⇒ boolean + * [.addView(view, show)](#module_view/Pane..Pane+addView) + * [.showView(view)](#module_view/Pane..Pane+showView) + * [.updateLayout(forceRefresh)](#module_view/Pane..Pane+updateLayout) + * [.getCurrentlyViewedFile()](#module_view/Pane..Pane+getCurrentlyViewedFile) ⇒ File + * [.getCurrentlyViewedEditor()](#module_view/Pane..Pane+getCurrentlyViewedEditor) ⇒ File + * [.getCurrentlyViewedPath()](#module_view/Pane..Pane+getCurrentlyViewedPath) ⇒ string + * [.destroyViewIfNotNeeded(view)](#module_view/Pane..Pane+destroyViewIfNotNeeded) + * [.removeView(file, suppressOpenNextFile, preventViewChange)](#module_view/Pane..Pane+removeView) ⇒ boolean + * [.removeViews(list)](#module_view/Pane..Pane+removeViews) ⇒ Array.<File> + * [.focus()](#module_view/Pane..Pane+focus) + * [.loadState(state)](#module_view/Pane..Pane+loadState) ⇒ jQuery.Promise + * [.saveState()](#module_view/Pane..Pane+saveState) ⇒ Object + * [.getScrollState()](#module_view/Pane..Pane+getScrollState) ⇒ Object + * [.restoreAndAdjustScrollState([state], [heightDelta])](#module_view/Pane..Pane+restoreAndAdjustScrollState) + + + +### view/Pane.Pane +**Kind**: inner class of [view/Pane](#module_view/Pane) **See**: [MainViewManager](MainViewManager) for more information -* [Pane](#Pane) - * [new Pane(id, $container)](#new_Pane_new) - * [.id](#Pane+id) : string - * [.$container](#Pane+$container) : JQuery - * [.$el](#Pane+$el) : JQuery - * [.$header](#Pane+$header) : JQuery - * [.$headerText](#Pane+$headerText) : JQuery - * [.$headerFlipViewBtn](#Pane+$headerFlipViewBtn) : JQuery - * [.$headerCloseBtn](#Pane+$headerCloseBtn) : JQuery - * [.$content](#Pane+$content) : JQuery - * [.ITEM_NOT_FOUND](#Pane+ITEM_NOT_FOUND) - * [.ITEM_FOUND_NO_SORT](#Pane+ITEM_FOUND_NO_SORT) - * [.ITEM_FOUND_NEEDS_SORT](#Pane+ITEM_FOUND_NEEDS_SORT) - * [.mergeFrom(other)](#Pane+mergeFrom) - * [.destroy()](#Pane+destroy) - * [.getViewList()](#Pane+getViewList) ⇒ Array.<File> - * [.getViewListSize()](#Pane+getViewListSize) ⇒ number - * [.findInViewList(fullPath)](#Pane+findInViewList) ⇒ number - * [.findInViewListAddedOrder(fullPath)](#Pane+findInViewListAddedOrder) ⇒ number - * [.findInViewListMRUOrder(fullPath)](#Pane+findInViewListMRUOrder) ⇒ number - * [.reorderItem(file, [index], [force])](#Pane+reorderItem) ⇒ number - * [.addToViewList(file, [index])](#Pane+addToViewList) ⇒ number - * [.addListToViewList(fileList)](#Pane+addListToViewList) ⇒ Array.<File> - * [.makeViewMostRecent(file)](#Pane+makeViewMostRecent) - * [.sortViewList(compareFn)](#Pane+sortViewList) - * [.swapViewListIndexes(index1, index2)](#Pane+swapViewListIndexes) ⇒ boolean - * [.traverseViewListByMRU(direction, [current])](#Pane+traverseViewListByMRU) ⇒ File - * [.showInterstitial(show)](#Pane+showInterstitial) - * [.getViewForPath(path)](#Pane+getViewForPath) ⇒ boolean - * [.addView(view, show)](#Pane+addView) - * [.showView(view)](#Pane+showView) - * [.updateLayout(forceRefresh)](#Pane+updateLayout) - * [.getCurrentlyViewedFile()](#Pane+getCurrentlyViewedFile) ⇒ File - * [.getCurrentlyViewedEditor()](#Pane+getCurrentlyViewedEditor) ⇒ File - * [.getCurrentlyViewedPath()](#Pane+getCurrentlyViewedPath) ⇒ string - * [.destroyViewIfNotNeeded(view)](#Pane+destroyViewIfNotNeeded) - * [.removeView(file, suppressOpenNextFile, preventViewChange)](#Pane+removeView) ⇒ boolean - * [.removeViews(list)](#Pane+removeViews) ⇒ Array.<File> - * [.focus()](#Pane+focus) - * [.loadState(state)](#Pane+loadState) ⇒ jQuery.Promise - * [.saveState()](#Pane+saveState) ⇒ Object - * [.getScrollState()](#Pane+getScrollState) ⇒ Object - * [.restoreAndAdjustScrollState([state], [heightDelta])](#Pane+restoreAndAdjustScrollState) - - - -### new Pane(id, $container) +* [.Pane](#module_view/Pane..Pane) + * [new Pane(id, $container)](#new_module_view/Pane..Pane_new) + * [.id](#module_view/Pane..Pane+id) : string + * [.$container](#module_view/Pane..Pane+$container) : JQuery + * [.$el](#module_view/Pane..Pane+$el) : JQuery + * [.$header](#module_view/Pane..Pane+$header) : JQuery + * [.$headerText](#module_view/Pane..Pane+$headerText) : JQuery + * [.$headerFlipViewBtn](#module_view/Pane..Pane+$headerFlipViewBtn) : JQuery + * [.$headerCloseBtn](#module_view/Pane..Pane+$headerCloseBtn) : JQuery + * [.$content](#module_view/Pane..Pane+$content) : JQuery + * [.ITEM_NOT_FOUND](#module_view/Pane..Pane+ITEM_NOT_FOUND) + * [.ITEM_FOUND_NO_SORT](#module_view/Pane..Pane+ITEM_FOUND_NO_SORT) + * [.ITEM_FOUND_NEEDS_SORT](#module_view/Pane..Pane+ITEM_FOUND_NEEDS_SORT) + * [.mergeFrom(other)](#module_view/Pane..Pane+mergeFrom) + * [.destroy()](#module_view/Pane..Pane+destroy) + * [.getViewList()](#module_view/Pane..Pane+getViewList) ⇒ Array.<File> + * [.getViewListSize()](#module_view/Pane..Pane+getViewListSize) ⇒ number + * [.findInViewList(fullPath)](#module_view/Pane..Pane+findInViewList) ⇒ number + * [.findInViewListAddedOrder(fullPath)](#module_view/Pane..Pane+findInViewListAddedOrder) ⇒ number + * [.findInViewListMRUOrder(fullPath)](#module_view/Pane..Pane+findInViewListMRUOrder) ⇒ number + * [.reorderItem(file, [index], [force])](#module_view/Pane..Pane+reorderItem) ⇒ number + * [.addToViewList(file, [index])](#module_view/Pane..Pane+addToViewList) ⇒ number + * [.addListToViewList(fileList)](#module_view/Pane..Pane+addListToViewList) ⇒ Array.<File> + * [.makeViewMostRecent(file)](#module_view/Pane..Pane+makeViewMostRecent) + * [.sortViewList(compareFn)](#module_view/Pane..Pane+sortViewList) + * [.swapViewListIndexes(index1, index2)](#module_view/Pane..Pane+swapViewListIndexes) ⇒ boolean + * [.traverseViewListByMRU(direction, [current])](#module_view/Pane..Pane+traverseViewListByMRU) ⇒ File + * [.showInterstitial(show)](#module_view/Pane..Pane+showInterstitial) + * [.getViewForPath(path)](#module_view/Pane..Pane+getViewForPath) ⇒ boolean + * [.addView(view, show)](#module_view/Pane..Pane+addView) + * [.showView(view)](#module_view/Pane..Pane+showView) + * [.updateLayout(forceRefresh)](#module_view/Pane..Pane+updateLayout) + * [.getCurrentlyViewedFile()](#module_view/Pane..Pane+getCurrentlyViewedFile) ⇒ File + * [.getCurrentlyViewedEditor()](#module_view/Pane..Pane+getCurrentlyViewedEditor) ⇒ File + * [.getCurrentlyViewedPath()](#module_view/Pane..Pane+getCurrentlyViewedPath) ⇒ string + * [.destroyViewIfNotNeeded(view)](#module_view/Pane..Pane+destroyViewIfNotNeeded) + * [.removeView(file, suppressOpenNextFile, preventViewChange)](#module_view/Pane..Pane+removeView) ⇒ boolean + * [.removeViews(list)](#module_view/Pane..Pane+removeViews) ⇒ Array.<File> + * [.focus()](#module_view/Pane..Pane+focus) + * [.loadState(state)](#module_view/Pane..Pane+loadState) ⇒ jQuery.Promise + * [.saveState()](#module_view/Pane..Pane+saveState) ⇒ Object + * [.getScrollState()](#module_view/Pane..Pane+getScrollState) ⇒ Object + * [.restoreAndAdjustScrollState([state], [heightDelta])](#module_view/Pane..Pane+restoreAndAdjustScrollState) + + + +#### new Pane(id, $container) Pane Objects are constructed by the MainViewManager object when a Pane view is needed. @@ -64,145 +240,145 @@ Pane Objects are constructed by the MainViewManager object when a Pane view is n | id | string | The id to use to identify this pane. | | $container | jQuery | The parent jQuery container to place the pane view. | - + -### pane.id : string +#### pane.id : string id of the pane -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$container : JQuery +#### pane.$container : JQuery container where the pane lives -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$el : JQuery +#### pane.$el : JQuery the wrapped DOM node of this pane -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$header : JQuery +#### pane.$header : JQuery the wrapped DOM node container that contains name of current view and the switch view button, or informational string if there is no view -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$headerText : JQuery +#### pane.$headerText : JQuery the wrapped DOM node that contains name of current view, or informational string if there is no view -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$headerFlipViewBtn : JQuery +#### pane.$headerFlipViewBtn : JQuery the wrapped DOM node that is used to flip the view to another pane -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$headerCloseBtn : JQuery +#### pane.$headerCloseBtn : JQuery close button of the pane -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.$content : JQuery +#### pane.$content : JQuery the wrapped DOM node that contains views -**Kind**: instance property of [Pane](#Pane) +**Kind**: instance property of [Pane](#module_view/Pane..Pane) **Read only**: true - + -### pane.ITEM\_NOT\_FOUND +#### pane.ITEM\_NOT\_FOUND Return value from reorderItem when the Item was not found -**Kind**: instance constant of [Pane](#Pane) -**See**: [reorderItem](#Pane+reorderItem) - +**Kind**: instance constant of [Pane](#module_view/Pane..Pane) +**See**: [Pane#reorderItem](Pane#reorderItem) + -### pane.ITEM\_FOUND\_NO\_SORT +#### pane.ITEM\_FOUND\_NO\_SORT Return value from reorderItem when the Item was found at its natural index and the workingset does not need to be resorted -**Kind**: instance constant of [Pane](#Pane) -**See**: [reorderItem](#Pane+reorderItem) - +**Kind**: instance constant of [Pane](#module_view/Pane..Pane) +**See**: [Pane#reorderItem](Pane#reorderItem) + -### pane.ITEM\_FOUND\_NEEDS\_SORT +#### pane.ITEM\_FOUND\_NEEDS\_SORT Return value from reorderItem when the Item was found and reindexed and the workingset needs to be resorted -**Kind**: instance constant of [Pane](#Pane) -**See**: [reorderItem](#Pane+reorderItem) - +**Kind**: instance constant of [Pane](#module_view/Pane..Pane) +**See**: [Pane#reorderItem](Pane#reorderItem) + -### pane.mergeFrom(other) +#### pane.mergeFrom(other) Merges the another Pane object's contents into this Pane -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | -| other | [Pane](#Pane) | Pane from which to copy | +| other | Pane | Pane from which to copy | - + -### pane.destroy() +#### pane.destroy() Removes the DOM node for the Pane, removes all event handlers and _resets all internal data structures -**Kind**: instance method of [Pane](#Pane) - +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + -### pane.getViewList() ⇒ Array.<File> +#### pane.getViewList() ⇒ Array.<File> Returns a copy of the view file list -**Kind**: instance method of [Pane](#Pane) - +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + -### pane.getViewListSize() ⇒ number +#### pane.getViewListSize() ⇒ number Returns the number of entries in the view file list -**Kind**: instance method of [Pane](#Pane) - +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + -### pane.findInViewList(fullPath) ⇒ number +#### pane.findInViewList(fullPath) ⇒ number Returns the index of the item in the view file list -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: number - index of the item or -1 if not found | Param | Type | Description | | --- | --- | --- | | fullPath | string | the full path of the item to look for | - + -### pane.findInViewListAddedOrder(fullPath) ⇒ number +#### pane.findInViewListAddedOrder(fullPath) ⇒ number Returns the order in which the item was added -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: number - order of the item or -1 if not found | Param | Type | Description | | --- | --- | --- | | fullPath | string | the full path of the item to look for | - + -### pane.findInViewListMRUOrder(fullPath) ⇒ number +#### pane.findInViewListMRUOrder(fullPath) ⇒ number Returns the order in which the item was last used -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: number - order of the item or -1 if not found. 0 indicates most recently used, followed by 1 and so on... @@ -210,12 +386,12 @@ Returns the order in which the item was last used | --- | --- | --- | | fullPath | string | the full path of the item to look for | - + -### pane.reorderItem(file, [index], [force]) ⇒ number +#### pane.reorderItem(file, [index], [force]) ⇒ number reorders the specified file in the view list to the desired position -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: number - this function returns one of the following manifest constants: ITEM_NOT_FOUND : The request file object was not found ITEM_FOUND_NO_SORT : The request file object was found but it was already at the requested index @@ -227,13 +403,13 @@ reorders the specified file in the view list to the desired position | [index] | number | the new position of the item | | [force] | boolean | true to force the item into that position, false otherwise. (Requires an index be requested) | - + -### pane.addToViewList(file, [index]) ⇒ number +#### pane.addToViewList(file, [index]) ⇒ number Adds the given file to the end of the workingset, if it is not already in the list Does not change which document is currently open in the editor. Completes synchronously. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: number - index of where the item was added | Param | Type | Description | @@ -241,46 +417,46 @@ Does not change which document is currently open in the editor. Completes synchr | file | File | file to add | | [index] | number | position where to add the item | - + -### pane.addListToViewList(fileList) ⇒ Array.<File> +#### pane.addListToViewList(fileList) ⇒ Array.<File> Adds the given file list to the end of the workingset. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: Array.<File> - list of files added to the list | Param | Type | | --- | --- | | fileList | Array.<File> | - + -### pane.makeViewMostRecent(file) +#### pane.makeViewMostRecent(file) Moves the specified file to the front of the MRU (Most Recently Used) list. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | file | File | The file to move to the front of the MRU list. | - + -### pane.sortViewList(compareFn) +#### pane.sortViewList(compareFn) Sorts items in the pane's view list. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | compareFn | function | The function used to compare items in the view list. | - + -### pane.swapViewListIndexes(index1, index2) ⇒ boolean +#### pane.swapViewListIndexes(index1, index2) ⇒ boolean Swaps two items in the file view list (used while dragging items in the working set view) -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: boolean - } true | Param | Type | Description | @@ -288,12 +464,12 @@ Swaps two items in the file view list (used while dragging items in the working | index1 | number | the index of the first item to swap | | index2 | number | the index of the second item to swap | - + -### pane.traverseViewListByMRU(direction, [current]) ⇒ File +#### pane.traverseViewListByMRU(direction, [current]) ⇒ File Traverses the list and returns the File object of the next item in the MRU order -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: File - The File object of the next item in the travesal order or null if there isn't one. | Param | Type | Description | @@ -301,103 +477,103 @@ Traverses the list and returns the File object of the next item in the MRU order | direction | number | Must be 1 or -1 to traverse forward or backward | | [current] | string | the fullPath of the item where traversal is to start. If this parameter is omitted then the path of the current view is used. If the current view is a temporary view then the first item in the MRU list is returned | - + -### pane.showInterstitial(show) +#### pane.showInterstitial(show) Shows the pane's interstitial page -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | show | boolean | show or hide the interstitial page | - + -### pane.getViewForPath(path) ⇒ boolean +#### pane.getViewForPath(path) ⇒ boolean retrieves the view object for the given path -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: boolean - show - show or hide the interstitial page | Param | Type | Description | | --- | --- | --- | | path | string | the fullPath of the view to retrieve | - + -### pane.addView(view, show) +#### pane.addView(view, show) Adds a view to the pane -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | view | View | the View object to add | | show | boolean | true to show the view right away, false otherwise | - + -### pane.showView(view) +#### pane.showView(view) Swaps the current view with the requested view. If the interstitial page is shown, it is hidden. If the currentView is a temporary view, it is destroyed. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | view | View | the to show | - + -### pane.updateLayout(forceRefresh) +#### pane.updateLayout(forceRefresh) Sets pane content height. Updates the layout causing the current view to redraw itself -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | forceRefresh | boolean | true to force a resize and refresh of the current view, false if just to resize forceRefresh is only used by Editor views to force a relayout of all editor DOM elements. Custom View implementations should just ignore this flag. | - + -### pane.getCurrentlyViewedFile() ⇒ File +#### pane.getCurrentlyViewedFile() ⇒ File Retrieves the File object of the current view -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: File - the File object of the current view or null if there isn't one - + -### pane.getCurrentlyViewedEditor() ⇒ File +#### pane.getCurrentlyViewedEditor() ⇒ File Retrieves the File object of the current view -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: File - the File object of the current view or null if there isn't one - + -### pane.getCurrentlyViewedPath() ⇒ string +#### pane.getCurrentlyViewedPath() ⇒ string Retrieves the path of the current view -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: string - the path of the current view or null if there isn't one - + -### pane.destroyViewIfNotNeeded(view) +#### pane.destroyViewIfNotNeeded(view) destroys the view if it isn't needed -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | view | View | the view to destroy | - + -### pane.removeView(file, suppressOpenNextFile, preventViewChange) ⇒ boolean +#### pane.removeView(file, suppressOpenNextFile, preventViewChange) ⇒ boolean Removes the view and opens the next view -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: boolean - true if the file was removed from the working set This function will remove a temporary view of a file but will return false in that case @@ -407,13 +583,13 @@ Removes the view and opens the next view | suppressOpenNextFile | boolean | suppresses opening the next file in MRU order | | preventViewChange | boolean | if suppressOpenNextFile is truthy, this flag can be used to prevent the current view from being destroyed. Ignored if suppressOpenNextFile is falsy | - + -### pane.removeViews(list) ⇒ Array.<File> +#### pane.removeViews(list) ⇒ Array.<File> Removes the specifed file from all internal lists, destroys the view of the file (if there is one) and shows the interstitial page if the current view is destroyed. -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: Array.<File> - Array of File objects removed from the working set. This function will remove temporary views but the file objects for those views will not be found in the result set. Only the file objects removed from the working set are returned. @@ -422,178 +598,47 @@ Removes the specifed file from all internal lists, destroys the view of the file | --- | --- | --- | | list | Array.<File> | Array of files to remove | - + -### pane.focus() +#### pane.focus() Gives focus to the last thing that had focus, the current view or the pane in that order -**Kind**: instance method of [Pane](#Pane) - +**Kind**: instance method of [Pane](#module_view/Pane..Pane) + -### pane.loadState(state) ⇒ jQuery.Promise +#### pane.loadState(state) ⇒ jQuery.Promise serializes the pane state from JSON -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: jQuery.Promise - A promise which resolves to \{fullPath:string, paneId:string} which can be passed as command data to FILE_OPEN | Param | Type | Description | | --- | --- | --- | | state | Object | the state to load | - + -### pane.saveState() ⇒ Object +#### pane.saveState() ⇒ Object Returns the JSON-ified state of the object so it can be serialize -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: Object - state - the state to save - + -### pane.getScrollState() ⇒ Object +#### pane.getScrollState() ⇒ Object gets the current view's scroll state data -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) **Returns**: Object - scroll state - the current scroll state - + -### pane.restoreAndAdjustScrollState([state], [heightDelta]) +#### pane.restoreAndAdjustScrollState([state], [heightDelta]) tells the current view to restore its scroll state from cached data and apply a height delta -**Kind**: instance method of [Pane](#Pane) +**Kind**: instance method of [Pane](#module_view/Pane..Pane) | Param | Type | Description | | --- | --- | --- | | [state] | Object | the current scroll state | | [heightDelta] | number | the amount to add or subtract from the state | - - -## \_ -Pane objects host views of files, editors, etc... Clients cannot access -Pane objects directly. Instead the implementation is protected by the -MainViewManager -- however View Factories are given a Pane object which -they can use to add views. References to Pane objects should not be kept -as they may be destroyed and removed from the DOM. - -To get a custom view, there are two components: - - 1) A View Factory - 2) A View Object - -View objects are anonymous object that have a particular interface. - -Views can be added to a pane but do not have to exist in the Pane object's view list. -Such views are "temporary views". Temporary views are not serialized with the Pane state -or reconstituted when the pane is serialized from disk. They are destroyed at the earliest -opportunity. - -Temporary views are added by calling `Pane.showView()` and passing it the view object. The view -will be destroyed when the next view is shown, the pane is mereged with another pane or the "Close All" -command is exectuted on the Pane. Temporary Editor Views do not contain any modifications and are -added to the workingset (and are no longer tempoary views) once the document has been modified. They -will remain in the working set until closed from that point on. - -Views that have a longer life span are added by calling addView to associate the view with a -filename in the _views object. These views are not destroyed until they are removed from the pane -by calling one of the following: removeView, removeViews, or _reset - -Pane Object Events: - - - viewListChange - Whenever there is a file change to a file in the working set. These 2 events: `DocumentManager.pathRemove` - and `DocumentManager.fileNameChange` will cause a `viewListChange` event so the WorkingSetView can update. - - - currentViewChange - Whenever the current view changes. - (e, newView:View, oldView:View) - - - viewDestroy - Whenever a view has been destroyed - (e, view:View) - -View Interface: - -The view is an anonymous object which has the following method signatures. see ImageViewer for an example or the sample -provided with Brackets `src/extensions/samples/BracketsConfigCentral` - -```js - { - $el:jQuery - getFile: function ():!File - updateLayout: function(forceRefresh:boolean) - destroy: function() - getScrollPos: function():*= - adjustScrollPos: function(state:Object=, heightDelta:number)= - notifyContainerChange: function()= - notifyVisibilityChange: function(boolean)= - focus:function()= - } -``` -When views are created they can be added to the pane by calling `pane.addView()`. -Views can be created and parented by attaching directly to `pane.$el` - - this._codeMirror = new CodeMirror(pane.$el, ...) - -Factories can create a view that's initially hidden by calling `pane.addView(view)` and passing `false` for the show parameter. -Hidden views can be later shown by calling `pane.showView(view)` - -`$el:jQuery!` - - property that stores the jQuery wrapped DOM element of the view. All views must have one so pane objects can manipulate the DOM - element when necessary (e.g. `showView`, `_reparent`, etc...) - -`getFile():File!` - - Called throughout the life of a View when the current file is queried by the system. - -`updateLayout(forceRefresh:boolean)` - - Called to notify the view that it should be resized to fit its parent container. This may be called several times - or only once. Views can ignore the `forceRefresh` flag. It is used for editor views to force a relayout of the editor - which probably isn't necessary for most views. Views should implement their html to be dynamic and not rely on this - function to be called whenever possible. - -`destroy()` - - Views must implement a destroy method to remove their DOM element at the very least. There is no default - implementation and views are hidden before this method is called. The Pane object doesn't make assumptions - about when it is safe to remove a node. In some instances other cleanup must take place before a the DOM - node is destroyed so the implementation details are left to the view. - - Views can implement a simple destroy by calling - - this.$el.remove() - - These members are optional and need not be implemented by Views - - getScrollPos() - adjustScrollPos() - - The system at various times will want to save and restore a view's scroll position. The data returned by `getScrollPos()` - is specific to the view and will be passed back to `adjustScrollPos()` when the scroll position needs to be restored. - - When Modal Bars are invoked, the system calls `getScrollPos()` so that the current scroll psotion of all visible Views can be cached. - That cached scroll position is later passed to `adjustScrollPos()` along with a height delta. The height delta is used to - scroll the view so that it doesn't appear to have "jumped" when invoking the Modal Bar. - - Height delta will be a positive when the Modal Bar is being shown and negative number when the Modal Bar is being hidden. - - `getViewState()` is another optional member that is used to cache a view's state when hiding or destroying a view or closing the project. - The data returned by this member is stored in `ViewStateManager` and is saved with the project. - - Views or View Factories are responsible for restoring the view state when the view of that file is created by recalling the cached state - - var view = createIconView(file, pane); - view.restoreViewState(ViewStateManager.getViewState(file.fullPath)); - - Notifications - The following optional methods receive notifications from the Pane object when certain events take place which affect the view: - -`notifyContainerChange()` - - Optional Notification callback called when the container changes. The view can perform any synchronization or state update - it needs to do when its parent container changes. - -`notifyVisiblityChange()` - - Optional Notification callback called when the view's vsibility changes. The view can perform any synchronization or - state update it needs to do when its visiblity state changes. - -**Kind**: global variable diff --git a/docs/API-Reference/view/SidebarTabs.md b/docs/API-Reference/view/SidebarTabs.md new file mode 100644 index 0000000000..fbbd4bdd60 --- /dev/null +++ b/docs/API-Reference/view/SidebarTabs.md @@ -0,0 +1,139 @@ +### Import : +```js +const SidebarTabs = brackets.getModule("view/SidebarTabs") +``` + + + +## view/SidebarTabs +SidebarTabs manages multiple tab panes within the sidebar. It inserts a +`#navTabBar` element after `#mainNavBar` and provides an API for registering +tabs, associating DOM content with tabs, and switching between them. + +Existing sidebar children that are not explicitly associated with a tab via +`addToTab` are treated as belonging to the default "Files" tab. This means +extensions that add DOM nodes to the sidebar will continue to work without +any code changes. + +Tab switching works purely by toggling the `.sidebar-tab-hidden` CSS class +(`display: none !important`). No DOM reparenting or detaching occurs, so +cached jQuery/DOM references held by extensions remain valid. + + +* [view/SidebarTabs](#module_view/SidebarTabs) + * [.SIDEBAR_TAB_FILES](#module_view/SidebarTabs..SIDEBAR_TAB_FILES) : string + * [.EVENT_TAB_ADDED](#module_view/SidebarTabs..EVENT_TAB_ADDED) : string + * [.EVENT_TAB_REMOVED](#module_view/SidebarTabs..EVENT_TAB_REMOVED) : string + * [.EVENT_TAB_CHANGED](#module_view/SidebarTabs..EVENT_TAB_CHANGED) : string + * [.addTab(id, label, iconClass, [options])](#module_view/SidebarTabs..addTab) + * [.addToTab(tabId, $content)](#module_view/SidebarTabs..addToTab) + * [.removeFromTab(tabId, $content)](#module_view/SidebarTabs..removeFromTab) + * [.removeTab(id)](#module_view/SidebarTabs..removeTab) ⇒ boolean + * [.setActiveTab(id)](#module_view/SidebarTabs..setActiveTab) + * [.getActiveTab()](#module_view/SidebarTabs..getActiveTab) ⇒ string + * [.getAllTabs()](#module_view/SidebarTabs..getAllTabs) ⇒ Array.<{id: string, label: string, iconClass: string, priority: number}> + + + +### view/SidebarTabs.SIDEBAR\_TAB\_FILES : string +The built-in Files tab id. + +**Kind**: inner constant of [view/SidebarTabs](#module_view/SidebarTabs) + + +### view/SidebarTabs.EVENT\_TAB\_ADDED : string +Fired when a new tab is registered via `addTab`. + +**Kind**: inner constant of [view/SidebarTabs](#module_view/SidebarTabs) + + +### view/SidebarTabs.EVENT\_TAB\_REMOVED : string +Fired when a tab is removed via `removeTab`. + +**Kind**: inner constant of [view/SidebarTabs](#module_view/SidebarTabs) + + +### view/SidebarTabs.EVENT\_TAB\_CHANGED : string +Fired when the active tab changes via `setActiveTab`. + +**Kind**: inner constant of [view/SidebarTabs](#module_view/SidebarTabs) + + +### view/SidebarTabs.addTab(id, label, iconClass, [options]) +Register a new sidebar tab. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| id | string | | Unique tab identifier | +| label | string | | Display text shown in the tab bar | +| iconClass | string | | FontAwesome (or other) icon class string | +| [options] | Object | | | +| [options.priority] | number | 100 | Lower values appear further left | + + + +### view/SidebarTabs.addToTab(tabId, $content) +Associate a DOM node (or jQuery element) with a tab. If the node is not +already a child of `#sidebar`, it is appended. If the tab is not the +currently active tab, the node is immediately hidden. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) + +| Param | Type | Description | +| --- | --- | --- | +| tabId | string | The tab to associate with | +| $content | jQuery \| Element | DOM node or jQuery wrapper | + + + +### view/SidebarTabs.removeFromTab(tabId, $content) +Remove a DOM node's association with a tab. If the node was appended by +`addToTab` (was not originally in the sidebar) and is no longer +associated with any tab, it is also removed from the DOM. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) + +| Param | Type | Description | +| --- | --- | --- | +| tabId | string | The tab to disassociate from | +| $content | jQuery \| Element | DOM node or jQuery wrapper | + + + +### view/SidebarTabs.removeTab(id) ⇒ boolean +Remove a tab entirely. Only succeeds if all content has been removed via +`removeFromTab` first. Returns false if content still exists. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) +**Returns**: boolean - true if removed, false if content still associated + +| Param | Type | Description | +| --- | --- | --- | +| id | string | The tab id to remove | + + + +### view/SidebarTabs.setActiveTab(id) +Switch the active sidebar tab. Shows nodes associated with the target +tab, hides all others. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) + +| Param | Type | Description | +| --- | --- | --- | +| id | string | The tab id to activate | + + + +### view/SidebarTabs.getActiveTab() ⇒ string +Get the currently active tab id. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) + + +### view/SidebarTabs.getAllTabs() ⇒ Array.<{id: string, label: string, iconClass: string, priority: number}> +Get an array of all registered tab descriptors. + +**Kind**: inner method of [view/SidebarTabs](#module_view/SidebarTabs) diff --git a/docs/API-Reference/view/ViewStateManager.md b/docs/API-Reference/view/ViewStateManager.md index b888fee6a1..e0092f2fdc 100644 --- a/docs/API-Reference/view/ViewStateManager.md +++ b/docs/API-Reference/view/ViewStateManager.md @@ -3,9 +3,9 @@ const ViewStateManager = brackets.getModule("view/ViewStateManager") ``` - + -## \_ +## view/ViewStateManager ViewStateManager is a singleton for views to park their global viwe state. The state is saved with project data but the View or View Factory is responsible for restoring the view state when the view is created. @@ -16,43 +16,49 @@ for later use. Views or View Factories are responsible for restoring the view state when the view of that file is created by recalling the cached state. Views determine what data is store in the view state and how to restore it. -**Kind**: global variable - -## reset() +* [view/ViewStateManager](#module_view/ViewStateManager) + * [.reset()](#module_view/ViewStateManager..reset) + * [.updateViewState(view, viewState)](#module_view/ViewStateManager..updateViewState) + * [.getViewState(file)](#module_view/ViewStateManager..getViewState) ⇒ \* + * [.addViewStates(viewStates)](#module_view/ViewStateManager..addViewStates) + + + +### view/ViewStateManager.reset() resets the view state cache -**Kind**: global function - +**Kind**: inner method of [view/ViewStateManager](#module_view/ViewStateManager) + -## updateViewState(view, viewState) +### view/ViewStateManager.updateViewState(view, viewState) Updates the view state for the specified view -**Kind**: global function +**Kind**: inner method of [view/ViewStateManager](#module_view/ViewStateManager) | Param | Type | Description | | --- | --- | --- | | view | Object | the to save state | | viewState | \* | any data that the view needs to restore the view state. | - + -## getViewState(file) ⇒ \* +### view/ViewStateManager.getViewState(file) ⇒ \* gets the view state for the specified file -**Kind**: global function +**Kind**: inner method of [view/ViewStateManager](#module_view/ViewStateManager) **Returns**: \* - whatever data that was saved earlier with a call setViewState | Param | Type | Description | | --- | --- | --- | | file | File | the file to record the view state for | - + -## addViewStates(viewStates) +### view/ViewStateManager.addViewStates(viewStates) adds an array of view states -**Kind**: global function +**Kind**: inner method of [view/ViewStateManager](#module_view/ViewStateManager) | Param | Type | Description | | --- | --- | --- | diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md index 34948a2665..8d39dbbaaf 100644 --- a/docs/API-Reference/view/WorkspaceManager.md +++ b/docs/API-Reference/view/WorkspaceManager.md @@ -3,21 +3,9 @@ const WorkspaceManager = brackets.getModule("view/WorkspaceManager") ``` - + -## PANEL\_TYPE\_BOTTOM\_PANEL : string -Constant representing the type of bottom panel - -**Kind**: global variable - - -## PANEL\_TYPE\_PLUGIN\_PANEL : string -Constant representing the type of plugin panel - -**Kind**: global variable - - -## AppInit +## view/WorkspaceManager Manages layout of panels surrounding the editor area, and size of the editor area (but not its contents). Updates panel sizes when the window is resized. Maintains the max resizing limits for panels, based on @@ -28,32 +16,62 @@ Events: The 2nd arg is the available workspace height. The 3rd arg is a refreshHint flag for internal use (passed in to recomputeLayout) -**Kind**: global constant - -## EVENT\_WORKSPACE\_UPDATE\_LAYOUT +* [view/WorkspaceManager](#module_view/WorkspaceManager) + * _static_ + * [.PANEL_TYPE_BOTTOM_PANEL](#module_view/WorkspaceManager.PANEL_TYPE_BOTTOM_PANEL) : string + * [.PANEL_TYPE_PLUGIN_PANEL](#module_view/WorkspaceManager.PANEL_TYPE_PLUGIN_PANEL) : string + * _inner_ + * [.EVENT_WORKSPACE_UPDATE_LAYOUT](#module_view/WorkspaceManager..EVENT_WORKSPACE_UPDATE_LAYOUT) + * [.EVENT_WORKSPACE_PANEL_SHOWN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_SHOWN) + * [.EVENT_WORKSPACE_PANEL_HIDDEN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_HIDDEN) + * [.createBottomPanel(id, $panel, [minSize])](#module_view/WorkspaceManager..createBottomPanel) ⇒ Panel + * [.createPluginPanel(id, $panel, [minSize], $toolbarIcon, [initialSize])](#module_view/WorkspaceManager..createPluginPanel) ⇒ Panel + * [.getAllPanelIDs()](#module_view/WorkspaceManager..getAllPanelIDs) ⇒ Array + * [.getPanelForID(panelID)](#module_view/WorkspaceManager..getPanelForID) ⇒ Object + * [.recomputeLayout(refreshHint)](#module_view/WorkspaceManager..recomputeLayout) + * [.isPanelVisible(panelID)](#module_view/WorkspaceManager..isPanelVisible) ⇒ boolean + * [.setPluginPanelWidth(width)](#module_view/WorkspaceManager..setPluginPanelWidth) + * [.addEscapeKeyEventHandler(consumerName, eventHandler)](#module_view/WorkspaceManager..addEscapeKeyEventHandler) ⇒ boolean + * [.removeEscapeKeyEventHandler(consumerName)](#module_view/WorkspaceManager..removeEscapeKeyEventHandler) ⇒ boolean + + + +### view/WorkspaceManager.PANEL\_TYPE\_BOTTOM\_PANEL : string +Constant representing the type of bottom panel + +**Kind**: static property of [view/WorkspaceManager](#module_view/WorkspaceManager) + + +### view/WorkspaceManager.PANEL\_TYPE\_PLUGIN\_PANEL : string +Constant representing the type of plugin panel + +**Kind**: static property of [view/WorkspaceManager](#module_view/WorkspaceManager) + + +### view/WorkspaceManager.EVENT\_WORKSPACE\_UPDATE\_LAYOUT Event triggered when the workspace layout updates. -**Kind**: global constant - +**Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) + -## EVENT\_WORKSPACE\_PANEL\_SHOWN +### view/WorkspaceManager.EVENT\_WORKSPACE\_PANEL\_SHOWN Event triggered when a panel is shown. -**Kind**: global constant - +**Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) + -## EVENT\_WORKSPACE\_PANEL\_HIDDEN +### view/WorkspaceManager.EVENT\_WORKSPACE\_PANEL\_HIDDEN Event triggered when a panel is hidden. -**Kind**: global constant - +**Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) + -## createBottomPanel(id, $panel, [minSize]) ⇒ Panel +### view/WorkspaceManager.createBottomPanel(id, $panel, [minSize]) ⇒ Panel Creates a new resizable panel beneath the editor area and above the status bar footer. Panel is initially invisible. The panel's size & visibility are automatically saved & restored as a view-state preference. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) | Param | Type | Description | | --- | --- | --- | @@ -61,14 +79,14 @@ The panel's size & visibility are automatically saved & restored as a view-state | $panel | jQueryObject | DOM content to use as the panel. Need not be in the document yet. Must have an id attribute, for use as a preferences key. | | [minSize] | number | Minimum height of panel in px. | - + -## createPluginPanel(id, $panel, [minSize], $toolbarIcon, [initialSize]) ⇒ Panel +### view/WorkspaceManager.createPluginPanel(id, $panel, [minSize], $toolbarIcon, [initialSize]) ⇒ Panel Creates a new resizable plugin panel associated with the given toolbar icon. Panel is initially invisible. The panel's size & visibility are automatically saved & restored. Only one panel can be associated with a toolbar icon. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) | Param | Type | Description | | --- | --- | --- | @@ -78,57 +96,71 @@ toolbar icon. | $toolbarIcon | jQueryObject | An icon that should be present in main-toolbar to associate this panel to. The panel will be shown only if the icon is visible on the toolbar and the user clicks on the icon. | | [initialSize] | number | Optional Initial size of panel in px. If not given, panel will use minsize or current size. | - + -## getAllPanelIDs() ⇒ Array +### view/WorkspaceManager.getAllPanelIDs() ⇒ Array Returns an array of all panel ID's -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) **Returns**: Array - List of ID's of all bottom panels - + -## getPanelForID(panelID) ⇒ Object +### view/WorkspaceManager.getPanelForID(panelID) ⇒ Object Gets the Panel interface for the given ID. Can return undefined if no panel with the ID is found. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) **Returns**: Object - Panel object for the ID or undefined | Param | Type | | --- | --- | | panelID | string | - + -## recomputeLayout(refreshHint) +### view/WorkspaceManager.recomputeLayout(refreshHint) Called when an external widget has appeared and needs some of the space occupied by the mainview manager -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) | Param | Type | Description | | --- | --- | --- | | refreshHint | boolean | true to refresh the editor, false if not | - + -## isPanelVisible(panelID) ⇒ boolean +### view/WorkspaceManager.isPanelVisible(panelID) ⇒ boolean Responsible to check if the panel is visible or not. Returns true if visible else false. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) | Param | | --- | | panelID | - + + +### view/WorkspaceManager.setPluginPanelWidth(width) +Programmatically sets the plugin panel content width to the given value in pixels. +The total toolbar width is adjusted to account for the plugin icons bar. +Width is clamped to respect panel minWidth and max size (75% of window). +No-op if no panel is currently visible. + +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) + +| Param | Type | Description | +| --- | --- | --- | +| width | number | Desired content width in pixels | + + -## addEscapeKeyEventHandler(consumerName, eventHandler) ⇒ boolean +### view/WorkspaceManager.addEscapeKeyEventHandler(consumerName, eventHandler) ⇒ boolean If any widgets related to the editor needs to handle the escape key event, add it here. returning true from the registered handler will prevent primary escape key toggle panel behavior of phoenix. Note that returning true will no stop the event bubbling, that has to be controlled with the event parameter forwarded to the handler. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) **Returns**: boolean - true if added | Param | Type | Description | @@ -136,12 +168,12 @@ will no stop the event bubbling, that has to be controlled with the event parame | consumerName | string | a unique name for your consumer | | eventHandler | function | If the eventHandler returns true for this callback, the escape key event will not lead to panel toggle default behavior. | - + -## removeEscapeKeyEventHandler(consumerName) ⇒ boolean +### view/WorkspaceManager.removeEscapeKeyEventHandler(consumerName) ⇒ boolean Removing the escape key event consumer. -**Kind**: global function +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) **Returns**: boolean - true if removed | Param | Type | Description | diff --git a/package-lock.json b/package-lock.json index ae112e8ece..2d1ac373ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "phoenix", - "version": "5.1.1-0", + "version": "5.1.4-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "phoenix", - "version": "5.1.1-0", + "version": "5.1.4-0", + "hasInstallScript": true, "dependencies": { "@bugsnag/js": "^7.18.0", "@floating-ui/dom": "^0.5.4", diff --git a/package.json b/package.json index 1f185417f9..e93657da42 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phoenix", - "version": "5.1.3-0", - "apiVersion": "5.1.3", + "version": "5.1.4-0", + "apiVersion": "5.1.4", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" @@ -42,6 +42,7 @@ "lmdb": "^3.5.1" }, "scripts": { + "postinstall": "npm install --prefix phoenix-builder-mcp", "lint": "eslint --quiet src test", "lint:fix": "eslint --quiet --fix src test", "prepare": "husky install", diff --git a/phoenix-builder-mcp/README.md b/phoenix-builder-mcp/README.md new file mode 100644 index 0000000000..9d088b8535 --- /dev/null +++ b/phoenix-builder-mcp/README.md @@ -0,0 +1,128 @@ +# Phoenix Builder MCP + +An MCP (Model Context Protocol) server that lets Claude Code launch, control, and inspect a running Phoenix Code instance. It also includes a Chrome extension that enables screenshot capture when Phoenix runs in a browser. + +## Prerequisites + +- Node.js +- The [phoenix-desktop](https://github.com/nicedoc/phoenix-desktop) repo cloned alongside this repo (i.e. `../phoenix-desktop`) + +## Setup + +### 1. Install dependencies + +```bash +cd phoenix-builder-mcp +npm install +``` + +### 2. Claude Code MCP configuration + +The project root already contains `.mcp.json` which registers the server automatically: + +```json +{ + "mcpServers": { + "phoenix-builder": { + "command": "node", + "args": ["phoenix-builder-mcp/index.js"], + "env": { + "PHOENIX_DESKTOP_PATH": "../phoenix-desktop" + } + } + } +} +``` + +Set `PHOENIX_DESKTOP_PATH` to the path of your phoenix-desktop checkout if it is not at `../phoenix-desktop`. + +You can also set `PHOENIX_MCP_WS_PORT` (default `38571`) to change the WebSocket port used for communication between the MCP server and the Phoenix browser runtime. + +### 3. Chrome extension (for browser screenshots) + +Screenshots work out of the box in the Electron/Tauri desktop app. If you are running Phoenix in a browser (e.g. `localhost` or `phcode.dev`), you need to install the Chrome extension: + +#### Loading as an unpacked extension (development) + +1. Open `chrome://extensions` in Chrome. +2. Enable **Developer mode** (toggle in the top-right corner). +3. Click **Load unpacked**. +4. Select the `phoenix-builder-mcp/chrome_extension/` directory. +5. The extension will appear as "Phoenix Code Screenshot". + +Once loaded, any Phoenix page on `localhost` or `phcode.dev` will have `window._phoenixScreenshotExtensionAvailable` set to `true`, and the `take_screenshot` MCP tool and `Phoenix.app.screenShotBinary()` API will work in the browser. + +#### Building a .zip for distribution + +```bash +cd phoenix-builder-mcp/chrome_extension +./build.sh +``` + +This produces `chrome_extension/build/phoenix-screenshot-extension.zip`. + +To build a signed `.crx` you need the Chrome binary and a private key: + +```bash +chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem +``` + +## MCP Tools + +Once the MCP server is running, the following tools are available in Claude Code: + +### `start_phoenix` +Launches the Phoenix Code Electron app by running `npm run serve:electron` in the phoenix-desktop directory. Returns the process PID and WebSocket port. + +### `stop_phoenix` +Stops the running Phoenix Code process (SIGTERM, then SIGKILL after 5s). + +### `get_phoenix_status` +Returns process status, PID, WebSocket connection state, connected instance names, and the WS port. + +### `get_terminal_logs` +Returns stdout/stderr from the Electron process. By default returns only new logs since the last call. Pass `clear: true` to get all logs and clear the buffer. + +### `get_browser_console_logs` +Returns `console.log`/`warn`/`error` output forwarded from the Phoenix browser runtime over WebSocket. Supports the same `clear` flag. When multiple Phoenix instances are connected, pass `instance` to target a specific one (e.g. `"Phoenix-a3f2"`). + +### `take_screenshot` +Captures a PNG screenshot of the Phoenix window. Optionally pass a `selector` (CSS selector string) to capture a specific element. Returns the image directly as `image/png`. + +In Electron/Tauri this uses the native capture API. In the browser it requires the Chrome extension (see above). + +### `reload_phoenix` +Reloads the Phoenix app. Prompts to save unsaved files before reloading. + +### `force_reload_phoenix` +Force-reloads the Phoenix app without saving unsaved changes. + +## Typical Claude Code workflow + +``` +> start_phoenix # launches the app +> take_screenshot # see what the UI looks like +> get_browser_console_logs # check for errors +> reload_phoenix # pick up code changes +> take_screenshot # verify the fix +> stop_phoenix # done +``` + +## Architecture + +``` +Claude Code <--stdio--> MCP Server (index.js) + | + +-- process-manager.js (spawns/kills Electron) + +-- ws-control-server.js (WebSocket on port 38571) + | + Phoenix browser runtime + (connects back over WS for logs, screenshots, reload) +``` + +For browser-mode screenshots the flow is: + +``` +MCP Server --WS--> Phoenix runtime --postMessage--> Content Script --chrome.runtime--> Background SW + (captureVisibleTab) +``` diff --git a/phoenix-builder-mcp/chrome_extension/background.js b/phoenix-builder-mcp/chrome_extension/background.js new file mode 100644 index 0000000000..aa83f8959f --- /dev/null +++ b/phoenix-builder-mcp/chrome_extension/background.js @@ -0,0 +1,13 @@ +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type !== "phoenix_screenshot_capture") { + return false; + } + chrome.tabs.captureVisibleTab(null, { format: "png" }) + .then(dataUrl => { + sendResponse({ success: true, dataUrl }); + }) + .catch(err => { + sendResponse({ success: false, error: err.message || String(err) }); + }); + return true; // keep channel open for async sendResponse +}); diff --git a/phoenix-builder-mcp/chrome_extension/build.sh b/phoenix-builder-mcp/chrome_extension/build.sh new file mode 100755 index 0000000000..45fcd1e96e --- /dev/null +++ b/phoenix-builder-mcp/chrome_extension/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Builds a .zip of the Chrome extension for distribution or local install. +# Usage: ./build.sh +# +# To load as an unpacked extension during development: +# 1. Open chrome://extensions +# 2. Enable "Developer mode" +# 3. Click "Load unpacked" and select this directory +# +# To build a .crx (signed package) you need the Chrome binary and a private key: +# chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +zip -j "$BUILD_DIR/phoenix-screenshot-extension.zip" \ + "$SCRIPT_DIR/manifest.json" \ + "$SCRIPT_DIR/background.js" \ + "$SCRIPT_DIR/content-script.js" \ + "$SCRIPT_DIR/page-script.js" + +echo "Built: $BUILD_DIR/phoenix-screenshot-extension.zip" diff --git a/phoenix-builder-mcp/chrome_extension/content-script.js b/phoenix-builder-mcp/chrome_extension/content-script.js new file mode 100644 index 0000000000..a6993556fb --- /dev/null +++ b/phoenix-builder-mcp/chrome_extension/content-script.js @@ -0,0 +1,27 @@ +// Relay screenshot requests from the page to the background service worker. +// The availability flag (window._phoenixScreenshotExtensionAvailable) is set by +// page-script.js which runs in the MAIN world via the manifest. +window.addEventListener("message", (event) => { + if (event.source !== window || !event.data || event.data.type !== "phoenix_screenshot_request") { + return; + } + const requestId = event.data.id; + chrome.runtime.sendMessage({ type: "phoenix_screenshot_capture" }, (response) => { + if (chrome.runtime.lastError) { + window.postMessage({ + type: "phoenix_screenshot_response", + id: requestId, + success: false, + error: chrome.runtime.lastError.message || "Extension communication error" + }, "*"); + return; + } + window.postMessage({ + type: "phoenix_screenshot_response", + id: requestId, + success: response.success, + dataUrl: response.dataUrl, + error: response.error + }, "*"); + }); +}); diff --git a/phoenix-builder-mcp/chrome_extension/manifest.json b/phoenix-builder-mcp/chrome_extension/manifest.json new file mode 100644 index 0000000000..33e83db6ff --- /dev/null +++ b/phoenix-builder-mcp/chrome_extension/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "Phoenix Code Screenshot", + "version": "1.0.0", + "description": "Enables screenshot capture in Phoenix Code when running in the browser.", + "permissions": [], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "http://localhost/*", + "https://phcode.dev/*", + "https://*.phcode.dev/*" + ], + "js": ["page-script.js"], + "run_at": "document_start", + "all_frames": false, + "world": "MAIN" + }, + { + "matches": [ + "http://localhost/*", + "https://phcode.dev/*", + "https://*.phcode.dev/*" + ], + "js": ["content-script.js"], + "run_at": "document_start", + "all_frames": false + } + ] +} diff --git a/phoenix-builder-mcp/chrome_extension/page-script.js b/phoenix-builder-mcp/chrome_extension/page-script.js new file mode 100644 index 0000000000..9b76622271 --- /dev/null +++ b/phoenix-builder-mcp/chrome_extension/page-script.js @@ -0,0 +1,3 @@ +// Runs in the MAIN world (the page's own JS context) at document_start, +// so it executes before deferred modules like shell.js. +window._phoenixScreenshotExtensionAvailable = true; diff --git a/phoenix-builder-mcp/index.js b/phoenix-builder-mcp/index.js new file mode 100644 index 0000000000..4b8f7f282e --- /dev/null +++ b/phoenix-builder-mcp/index.js @@ -0,0 +1,73 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createWSControlServer } from "./ws-control-server.js"; +import { createProcessManager } from "./process-manager.js"; +import { registerTools } from "./mcp-tools.js"; +import { fileURLToPath } from "url"; +import path from "path"; +import fs from "fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PID_FILE = path.join(__dirname, ".mcp-server.pid"); + +// Kill any previous MCP server instance that wasn't cleaned up (e.g. parent crashed). +try { + const oldPid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10); + if (oldPid && oldPid !== process.pid) { + try { + process.kill(oldPid, "SIGTERM"); + // Wait up to 3 seconds for it to exit + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + try { + process.kill(oldPid, 0); // throws if process is gone + await new Promise(r => setTimeout(r, 100)); + } catch { + break; + } + } + } catch { + // Process already dead — nothing to do + } + } +} catch { + // No PID file or unreadable — first run +} +fs.writeFileSync(PID_FILE, String(process.pid)); + +function removePidFile() { + try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } +} + +const wsPort = parseInt(process.env.PHOENIX_MCP_WS_PORT || "38571", 10); +const phoenixDesktopPath = process.env.PHOENIX_DESKTOP_PATH + || path.resolve(__dirname, "../../phoenix-desktop"); + +const wsControlServer = createWSControlServer(wsPort); +const processManager = createProcessManager(); + +const server = new McpServer({ + name: "phoenix-builder", + version: "1.0.0" +}); + +registerTools(server, processManager, wsControlServer, phoenixDesktopPath); + +const transport = new StdioServerTransport(); +await server.connect(transport); + +process.on("SIGINT", async () => { + await processManager.stop(); + wsControlServer.close(); + removePidFile(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + await processManager.stop(); + wsControlServer.close(); + removePidFile(); + process.exit(0); +}); diff --git a/phoenix-builder-mcp/log-buffer.js b/phoenix-builder-mcp/log-buffer.js new file mode 100644 index 0000000000..f11b3d2a28 --- /dev/null +++ b/phoenix-builder-mcp/log-buffer.js @@ -0,0 +1,51 @@ +const MAX_ENTRIES = 10000; + +export class LogBuffer { + constructor() { + this._entries = []; + this._readIndex = 0; + this._totalPushed = 0; + } + + push(entry) { + this._entries.push(entry); + this._totalPushed++; + if (this._entries.length > MAX_ENTRIES) { + const overflow = this._entries.length - MAX_ENTRIES; + this._entries.splice(0, overflow); + this._readIndex = Math.max(0, this._readIndex - overflow); + } + } + + getAll() { + return this._entries.slice(); + } + + getSinceLastRead() { + const newEntries = this._entries.slice(this._readIndex); + this._readIndex = this._entries.length; + return newEntries; + } + + totalPushed() { + return this._totalPushed; + } + + getTail(n, before) { + const firstIndex = this._totalPushed - this._entries.length; + let endIdx = this._entries.length; + if (before != null) { + endIdx = Math.max(0, Math.min(this._entries.length, before - firstIndex)); + } + if (n === 0) { + return this._entries.slice(0, endIdx); + } + const startIdx = Math.max(0, endIdx - n); + return this._entries.slice(startIdx, endIdx); + } + + clear() { + this._entries = []; + this._readIndex = 0; + } +} diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js new file mode 100644 index 0000000000..7c466d3dda --- /dev/null +++ b/phoenix-builder-mcp/mcp-tools.js @@ -0,0 +1,405 @@ +import { z } from "zod"; + +const DEFAULT_MAX_CHARS = 10000; + +function _trimToCharBudget(lines, maxChars) { + let total = 0; + // Walk backwards (newest first) to keep the most recent entries + let startIdx = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + const cost = lines[i].length + 1; // +1 for newline + if (total + cost > maxChars) { break; } + total += cost; + startIdx = i; + } + return { lines: lines.slice(startIdx), trimmed: startIdx }; +} + +export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) { + server.tool( + "start_phoenix", + "Start the Phoenix Code desktop app (Electron). Launches npm run serve:electron in the phoenix-desktop directory.", + {}, + async () => { + try { + if (processManager.isRunning()) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: "Phoenix is already running", + pid: processManager.getPid() + }) + }] + }; + } + const result = await processManager.start(phoenixDesktopPath); + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + pid: result.pid, + wsPort: wsControlServer.getPort() + }) + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ success: false, error: err.message }) + }] + }; + } + } + ); + + server.tool( + "stop_phoenix", + "Stop the running Phoenix Code desktop app.", + {}, + async () => { + try { + const result = await processManager.stop(); + return { + content: [{ + type: "text", + text: JSON.stringify(result) + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ success: false, error: err.message }) + }] + }; + } + } + ); + + server.tool( + "get_terminal_logs", + "Get stdout/stderr output from the Electron process. Returns last 50 entries by default. " + + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + + "prefer filter + small tail to keep responses compact.", + { + clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read."), + tail: z.number().default(50).describe("Return last N entries. 0 = all."), + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), + filter: z.string().optional().describe("Optional regex to filter log entries by text content. Applied before tail/before."), + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") + }, + async ({ clear, tail, before, filter, maxChars }) => { + let logs; + if (clear) { + logs = processManager.getTerminalLogs(false); + processManager.clearTerminalLogs(); + } else { + logs = processManager.getTerminalLogs(true); + } + const totalEntries = processManager.getTerminalLogsTotalPushed(); + let filterRe; + if (filter) { + try { + filterRe = new RegExp(filter, "i"); + } catch (e) { + return { + content: [{ + type: "text", + text: `Invalid filter regex: ${e.message}` + }] + }; + } + logs = logs.filter(e => filterRe.test(e.text)); + } + const matchedEntries = logs.length; + const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries; + if (tail > 0) { + const startIdx = Math.max(0, endIdx - tail); + logs = logs.slice(startIdx, endIdx); + } else { + logs = logs.slice(0, endIdx); + } + let lines = logs.map(e => `[${e.stream}] ${e.text}`); + let trimmed = 0; + if (maxChars > 0) { + const result = _trimToCharBudget(lines, maxChars); + lines = result.lines; + trimmed = result.trimmed; + } + const showing = lines.length; + const rangeEnd = endIdx; + const rangeStart = rangeEnd - logs.length; + const actualStart = rangeStart + trimmed; + const hasMore = actualStart > 0; + let header = `[Logs: ${totalEntries} total`; + if (filter) { + header += `, ${matchedEntries} matched /${filter}/i`; + } + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; + if (trimmed > 0) { + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; + } + if (hasMore) { + header += ` hasMore=true, use before=${actualStart} to page back.`; + } + header += `]`; + const text = lines.join(""); + return { + content: [{ + type: "text", + text: text ? header + "\n" + text : "(no terminal logs)" + }] + }; + } + ); + + server.tool( + "get_browser_console_logs", + "Get console logs from the Phoenix browser runtime. Returns last 50 entries by default. " + + "This includes both browser-side console logs and Node.js (PhNode) logs, which are prefixed with 'PhNode:'. " + + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + + "prefer filter + small tail to keep responses compact.", + { + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected."), + tail: z.number().default(50).describe("Return last N entries. 0 = all."), + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), + filter: z.string().optional().describe("Optional regex to filter log entries by message content. Applied before tail/before."), + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") + }, + async ({ instance, tail, before, filter, maxChars }) => { + try { + const result = await wsControlServer.requestLogs(instance, { tail, before, filter }); + const entries = result.entries || []; + const totalEntries = result.totalEntries || entries.length; + const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length; + const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries; + let lines = entries.map(e => { + let ts = ""; + if (e.timestamp) { + // Show HH:MM:SS.mmm for compact display + const d = new Date(e.timestamp); + ts = d.toTimeString().slice(0, 8) + "." + + String(d.getMilliseconds()).padStart(3, "0") + " "; + } + return `[${ts}${e.level}] ${e.message}`; + }); + let trimmed = 0; + if (maxChars > 0) { + const trimResult = _trimToCharBudget(lines, maxChars); + lines = trimResult.lines; + trimmed = trimResult.trimmed; + } + const showing = lines.length; + const rangeStart = rangeEnd - entries.length; + const actualStart = rangeStart + trimmed; + const hasMore = actualStart > 0; + let header = `[Logs: ${totalEntries} total`; + if (filter) { + header += `, ${matchedEntries} matched /${filter}/i`; + } + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; + if (trimmed > 0) { + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; + } + if (hasMore) { + header += ` hasMore=true, use before=${actualStart} to page back.`; + } + header += `]`; + if (showing === 0) { + return { + content: [{ + type: "text", + text: "(no browser logs)" + }] + }; + } + const text = lines.join("\n"); + return { + content: [{ + type: "text", + text: header + "\n" + text + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "take_screenshot", + "Take a screenshot of the Phoenix Code app window. Returns a PNG image.", + { + selector: z.string().optional().describe("Optional CSS selector to capture a specific element"), + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ selector, instance }) => { + try { + const base64Data = await wsControlServer.requestScreenshot(selector, instance); + return { + content: [{ + type: "image", + data: base64Data, + mimeType: "image/png" + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "reload_phoenix", + "Reload the Phoenix Code app. Closes all open files (prompting to save unsaved changes) then reloads the app.", + { + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ instance }) => { + try { + const result = await wsControlServer.requestReload(false, instance); + return { + content: [{ + type: "text", + text: JSON.stringify({ success: true, message: "Phoenix is reloading" }) + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "force_reload_phoenix", + "Force reload the Phoenix Code app without saving. Closes all open files without saving unsaved changes, then reloads the app.", + { + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ instance }) => { + try { + const result = await wsControlServer.requestReload(true, instance); + return { + content: [{ + type: "text", + text: JSON.stringify({ success: true, message: "Phoenix is force reloading (unsaved changes discarded)" }) + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "exec_js", + "Execute JavaScript in the Phoenix Code browser runtime and return the result. " + + "Code runs async in the page context with access to: " + + "$ (jQuery) for DOM queries/clicks, " + + "brackets.test.CommandManager, brackets.test.EditorManager, brackets.test.ProjectManager, " + + "brackets.test.DocumentManager, brackets.test.FileSystem, brackets.test.FileUtils, " + + "and 50+ other modules on brackets.test.* — " + + "supports await.", + { + code: z.string().describe("JavaScript code to execute in Phoenix"), + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ code, instance }) => { + try { + const result = await wsControlServer.requestExecJs(code, instance); + return { + content: [{ + type: "text", + text: result !== undefined ? String(result) : "(undefined)" + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "exec_js_in_live_preview", + "Execute JavaScript in the live preview iframe (the page being previewed), NOT in Phoenix itself. " + + "Auto-opens the live preview panel if it is not already visible. " + + "Code is evaluated via eval() in the global scope of the previewed page. " + + "Note: eval() is synchronous — async/await is NOT supported. " + + "Only available when an HTML file is selected in the live preview — " + + "does not work for markdown or other non-HTML file types. " + + "Use this to inspect or manipulate the user's live-previewed web page (e.g. document.title, DOM queries).", + { + code: z.string().describe("JavaScript code to execute in the live preview iframe"), + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") + }, + async ({ code, instance }) => { + try { + const result = await wsControlServer.requestExecJsLivePreview(code, instance); + return { + content: [{ + type: "text", + text: result !== undefined ? String(result) : "(undefined)" + }] + }; + } catch (err) { + return { + content: [{ + type: "text", + text: JSON.stringify({ error: err.message }) + }] + }; + } + } + ); + + server.tool( + "get_phoenix_status", + "Check the status of the Phoenix process and WebSocket connection.", + {}, + async () => { + return { + content: [{ + type: "text", + text: JSON.stringify({ + processRunning: processManager.isRunning(), + pid: processManager.getPid(), + wsConnected: wsControlServer.isClientConnected(), + connectedInstances: wsControlServer.getConnectedInstances(), + wsPort: wsControlServer.getPort() + }) + }] + }; + } + ); +} diff --git a/phoenix-builder-mcp/package-lock.json b/phoenix-builder-mcp/package-lock.json new file mode 100644 index 0000000000..64359b6072 --- /dev/null +++ b/phoenix-builder-mcp/package-lock.json @@ -0,0 +1,1164 @@ +{ + "name": "phoenix-builder-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phoenix-builder-mcp", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "latest", + "ws": "^8.0.0", + "zod": "^3.25.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/phoenix-builder-mcp/package.json b/phoenix-builder-mcp/package.json new file mode 100644 index 0000000000..48c700d3e3 --- /dev/null +++ b/phoenix-builder-mcp/package.json @@ -0,0 +1,12 @@ +{ + "name": "phoenix-builder-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "latest", + "ws": "^8.0.0", + "zod": "^3.25.0" + } +} diff --git a/phoenix-builder-mcp/process-manager.js b/phoenix-builder-mcp/process-manager.js new file mode 100644 index 0000000000..cafc8b0289 --- /dev/null +++ b/phoenix-builder-mcp/process-manager.js @@ -0,0 +1,128 @@ +import { spawn } from "child_process"; +import { LogBuffer } from "./log-buffer.js"; + +export function createProcessManager() { + let childProcess = null; + const terminalLogs = new LogBuffer(); + + function start(phoenixDesktopPath) { + if (childProcess) { + throw new Error("Phoenix is already running. Stop it first."); + } + + return new Promise((resolve, reject) => { + const child = spawn("npm", ["run", "serve:electron"], { + cwd: phoenixDesktopPath, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env } + }); + + childProcess = child; + + child.stdout.on("data", (data) => { + const text = data.toString(); + terminalLogs.push({ + stream: "stdout", + text, + timestamp: new Date().toISOString() + }); + }); + + child.stderr.on("data", (data) => { + const text = data.toString(); + terminalLogs.push({ + stream: "stderr", + text, + timestamp: new Date().toISOString() + }); + }); + + child.on("error", (err) => { + terminalLogs.push({ + stream: "stderr", + text: `Process error: ${err.message}`, + timestamp: new Date().toISOString() + }); + childProcess = null; + reject(err); + }); + + child.on("exit", (code, signal) => { + terminalLogs.push({ + stream: "stderr", + text: `Process exited with code=${code} signal=${signal}`, + timestamp: new Date().toISOString() + }); + childProcess = null; + }); + + // Give the process a moment to start or fail + setTimeout(() => { + if (childProcess) { + resolve({ pid: child.pid }); + } + }, 500); + }); + } + + function stop() { + return new Promise((resolve) => { + if (!childProcess) { + resolve({ success: true, message: "No process running" }); + return; + } + + const child = childProcess; + let killed = false; + + const forceKillTimeout = setTimeout(() => { + if (childProcess === child) { + child.kill("SIGKILL"); + killed = true; + } + }, 5000); + + child.on("exit", () => { + clearTimeout(forceKillTimeout); + childProcess = null; + resolve({ success: true, forced: killed }); + }); + + child.kill("SIGTERM"); + }); + } + + function isRunning() { + return childProcess !== null; + } + + function getPid() { + return childProcess ? childProcess.pid : null; + } + + function getTerminalLogs(sinceLast) { + if (sinceLast) { + return terminalLogs.getSinceLastRead(); + } + return terminalLogs.getAll(); + } + + function clearTerminalLogs() { + terminalLogs.clear(); + } + + function getTerminalLogsTotalPushed() { + return terminalLogs.totalPushed(); + } + + return { + start, + stop, + isRunning, + getPid, + getTerminalLogs, + clearTerminalLogs, + getTerminalLogsTotalPushed + }; +} diff --git a/phoenix-builder-mcp/ws-control-server.js b/phoenix-builder-mcp/ws-control-server.js new file mode 100644 index 0000000000..452a2ac866 --- /dev/null +++ b/phoenix-builder-mcp/ws-control-server.js @@ -0,0 +1,452 @@ +import { WebSocketServer } from "ws"; +import { LogBuffer } from "./log-buffer.js"; + +export function createWSControlServer(port) { + const wss = new WebSocketServer({ port }); + const clients = new Map(); // name -> { ws, logs, isAlive } + let unknownCounter = 0; + let requestIdCounter = 0; + const pendingRequests = new Map(); + let heartbeatInterval = null; + + wss.on("connection", (ws) => { + // Name is assigned when the client sends a "hello" message. + // Track the ws temporarily so we can map it back on close/error. + let clientName = null; + + ws.on("message", (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + + switch (msg.type) { + case "hello": { + clientName = msg.name || ("Unknown-" + (++unknownCounter)); + + // If same name reconnects (e.g. tab reload), close old connection + // but preserve the existing log buffer so logs survive across reloads + const existing = clients.get(clientName); + if (existing) { + try { + existing.ws.close(1000, "Replaced by new connection"); + } catch { + // ignore + } + clients.set(clientName, { + ws: ws, + logs: existing.logs, + isAlive: true + }); + } else { + clients.set(clientName, { + ws: ws, + logs: new LogBuffer(), + isAlive: true + }); + } + break; + } + + case "console_log": { + const client = clientName && clients.get(clientName); + if (client && Array.isArray(msg.entries)) { + for (const entry of msg.entries) { + client.logs.push(entry); + } + } + break; + } + + case "screenshot_response": { + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve(msg.data); + } + break; + } + + case "get_logs_response": { + const pending4 = pendingRequests.get(msg.id); + if (pending4) { + pendingRequests.delete(msg.id); + pending4.resolve({ + entries: msg.entries || [], + totalEntries: msg.totalEntries || (msg.entries ? msg.entries.length : 0), + matchedEntries: msg.matchedEntries, + rangeEnd: msg.rangeEnd + }); + } + break; + } + + case "exec_js_response": { + const pending5 = pendingRequests.get(msg.id); + if (pending5) { + pendingRequests.delete(msg.id); + if (msg.error) { + pending5.reject(new Error(msg.error)); + } else { + pending5.resolve(msg.result); + } + } + break; + } + + case "exec_js_live_preview_response": { + const pending6 = pendingRequests.get(msg.id); + if (pending6) { + pendingRequests.delete(msg.id); + if (msg.error) { + pending6.reject(new Error(msg.error)); + } else { + pending6.resolve(msg.result); + } + } + break; + } + + case "reload_response": { + const pending3 = pendingRequests.get(msg.id); + if (pending3) { + pendingRequests.delete(msg.id); + if (msg.success) { + pending3.resolve({ success: true }); + } else { + pending3.reject(new Error(msg.message || "Reload failed")); + } + } + break; + } + + case "error": { + const pending2 = pendingRequests.get(msg.id); + if (pending2) { + pendingRequests.delete(msg.id); + pending2.reject(new Error(msg.message || "Unknown error from Phoenix")); + } + break; + } + + case "pong": { + const client = clientName && clients.get(clientName); + if (client) { + client.isAlive = true; + } + break; + } + } + }); + + ws.on("close", () => { + if (clientName && clients.get(clientName)?.ws === ws) { + clients.delete(clientName); + } + }); + + ws.on("error", () => { + if (clientName && clients.get(clientName)?.ws === ws) { + clients.delete(clientName); + } + }); + }); + + // Heartbeat + heartbeatInterval = setInterval(() => { + for (const [name, client] of clients) { + if (!client.isAlive) { + client.ws.terminate(); + clients.delete(name); + continue; + } + client.isAlive = false; + try { + client.ws.send(JSON.stringify({ type: "ping" })); + } catch { + // ignore send errors + } + } + }, 15000); + + function _resolveClient(instanceName) { + if (clients.size === 0) { + return { error: "No Phoenix client connected" }; + } + + if (!instanceName) { + if (clients.size === 1) { + const [name, client] = [...clients.entries()][0]; + return { name, client }; + } + const names = [...clients.keys()]; + return { + error: "Multiple Phoenix instances connected. Specify an instance name: " + + names.join(", ") + }; + } + + const client = clients.get(instanceName); + if (!client) { + const names = [...clients.keys()]; + return { + error: "Instance \"" + instanceName + "\" not found. Available: " + + names.join(", ") + }; + } + + return { name: instanceName, client }; + } + + function requestScreenshot(selector, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("Screenshot request timed out (30s)")); + }, 30000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + const msg = { type: "screenshot_request", id }; + if (selector) { + msg.selector = selector; + } + client.ws.send(JSON.stringify(msg)); + }); + } + + function requestReload(forceClose, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("Reload request timed out (30s)")); + }, 30000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + client.ws.send(JSON.stringify({ + type: "reload_request", + id, + forceClose: !!forceClose + })); + }); + } + + function requestLogs(instanceName, { tail = 50, before, filter } = {}) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("Log request timed out (10s)")); + }, 10000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + const msg = { type: "get_logs_request", id, tail }; + if (before != null) { + msg.before = before; + } + if (filter) { + msg.filter = filter; + } + client.ws.send(JSON.stringify(msg)); + }); + } + + function requestExecJs(code, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("exec_js request timed out (30s)")); + }, 30000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + client.ws.send(JSON.stringify({ type: "exec_js_request", id, code })); + }); + } + + function requestExecJsLivePreview(code, instanceName) { + return new Promise((resolve, reject) => { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + reject(new Error(resolved.error)); + return; + } + + const { client } = resolved; + if (client.ws.readyState !== 1) { + reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected")); + return; + } + + const id = ++requestIdCounter; + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error("exec_js_live_preview request timed out (60s)")); + }, 60000); + + pendingRequests.set(id, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); + + client.ws.send(JSON.stringify({ type: "exec_js_live_preview_request", id, code })); + }); + } + + function getBrowserLogs(sinceLast, instanceName) { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + return { error: resolved.error }; + } + + const { client } = resolved; + if (sinceLast) { + return client.logs.getSinceLastRead(); + } + return client.logs.getAll(); + } + + function clearBrowserLogs(instanceName) { + const resolved = _resolveClient(instanceName); + if (resolved.error) { + return { error: resolved.error }; + } + resolved.client.logs.clear(); + } + + function isClientConnected() { + return clients.size > 0; + } + + function getConnectedInstances() { + return [...clients.keys()]; + } + + function close() { + clearInterval(heartbeatInterval); + for (const [id, pending] of pendingRequests) { + pending.reject(new Error("Server shutting down")); + } + pendingRequests.clear(); + for (const [name, client] of clients) { + try { + client.ws.close(1000, "Server shutting down"); + } catch { + // ignore + } + } + clients.clear(); + wss.close(); + } + + return { + requestScreenshot, + requestReload, + requestLogs, + requestExecJs, + requestExecJsLivePreview, + getBrowserLogs, + clearBrowserLogs, + isClientConnected, + getConnectedInstances, + close, + getPort: () => port + }; +} diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js new file mode 100644 index 0000000000..acced693c0 --- /dev/null +++ b/src-node/claude-code-agent.js @@ -0,0 +1,521 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Claude Code SDK integration via NodeConnector. + * + * Provides AI chat capabilities by bridging the Claude Code CLI/SDK + * with Phoenix's browser-side chat panel. Handles streaming responses, + * edit/write interception, and session management. + */ + +const { execSync } = require("child_process"); +const path = require("path"); + +const CONNECTOR_ID = "ph_ai_claude"; + +// Lazy-loaded ESM module reference +let queryModule = null; + +// Session state +let currentSessionId = null; + +// Active query state +let currentAbortController = null; + +// Streaming throttle +const TEXT_STREAM_THROTTLE_MS = 50; + +const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); + +/** + * Lazily import the ESM @anthropic-ai/claude-code module. + */ +async function getQueryFn() { + if (!queryModule) { + queryModule = await import("@anthropic-ai/claude-code"); + } + return queryModule.query; +} + +/** + * Find the user's globally installed Claude CLI, skipping node_modules copies. + */ +function findGlobalClaudeCli() { + const locations = [ + "/usr/local/bin/claude", + "/usr/bin/claude", + (process.env.HOME || "") + "/.local/bin/claude", + (process.env.HOME || "") + "/.nvm/versions/node/" + + (process.version.startsWith("v") ? process.version : "v" + process.version) + + "/bin/claude" + ]; + + // Try 'which -a' first to find all claude binaries, filtering out node_modules + try { + const allPaths = execSync("which -a claude 2>/dev/null || which claude", { encoding: "utf8" }) + .trim() + .split("\n") + .filter(p => p && !p.includes("node_modules")); + if (allPaths.length > 0) { + console.log("[Phoenix AI] Found global Claude CLI at:", allPaths[0]); + return allPaths[0]; + } + } catch { + // which failed, try manual locations + } + + // Check common locations + for (const loc of locations) { + try { + execSync(`test -x "${loc}"`, { encoding: "utf8" }); + console.log("[Phoenix AI] Found global Claude CLI at:", loc); + return loc; + } catch { + // Not found at this location + } + } + + console.log("[Phoenix AI] Global Claude CLI not found"); + return null; +} + +/** + * Check whether Claude CLI is available. + * Called from browser via execPeer("checkAvailability"). + */ +exports.checkAvailability = async function () { + try { + const claudePath = findGlobalClaudeCli(); + if (claudePath) { + // Also verify the SDK can be imported + await getQueryFn(); + return { available: true, claudePath: claudePath }; + } + // No global CLI found — try importing SDK anyway (it might find its own) + await getQueryFn(); + return { available: true, claudePath: null }; + } catch (err) { + return { available: false, claudePath: null, error: err.message }; + } +}; + +/** + * Send a prompt to Claude and stream results back to the browser. + * Called from browser via execPeer("sendPrompt", {prompt, projectPath, sessionAction, model}). + * + * Returns immediately with a requestId. Results are sent as events: + * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete + */ +exports.sendPrompt = async function (params) { + const { prompt, projectPath, sessionAction, model } = params; + const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + + // Handle session + if (sessionAction === "new") { + currentSessionId = null; + } + + // Cancel any in-flight query + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } + + currentAbortController = new AbortController(); + + // Run the query asynchronously — don't await here so we return requestId immediately + _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal) + .catch(err => { + console.error("[Phoenix AI] Query error:", err); + }); + + return { requestId: requestId }; +}; + +/** + * Cancel the current in-flight query. + */ +exports.cancelQuery = async function () { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + // Clear session so next query starts fresh instead of resuming a killed session + currentSessionId = null; + return { success: true }; + } + return { success: false }; +}; + +/** + * Destroy the current session (clear session ID). + */ +exports.destroySession = async function () { + currentSessionId = null; + currentAbortController = null; + return { success: true }; +}; + +/** + * Internal: run a Claude SDK query and stream results back to the browser. + */ +async function _runQuery(requestId, prompt, projectPath, model, signal) { + let editCount = 0; + let toolCounter = 0; + let queryFn; + + try { + queryFn = await getQueryFn(); + } catch (err) { + nodeConnector.triggerPeer("aiError", { + requestId: requestId, + error: "Failed to load Claude Code SDK: " + err.message + }); + return; + } + + // Send initial progress + nodeConnector.triggerPeer("aiProgress", { + requestId: requestId, + message: "Analyzing...", + phase: "start" + }); + + const queryOptions = { + cwd: projectPath || process.cwd(), + maxTurns: 10, + allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"], + permissionMode: "acceptEdits", + appendSystemPrompt: + "When modifying an existing file, always prefer the Edit tool " + + "(find-and-replace) instead of the Write tool. The Write tool should ONLY be used " + + "to create brand new files that do not exist yet. For existing files, always use " + + "multiple Edit calls to make targeted changes rather than rewriting the entire " + + "file with Write. This is critical because Write replaces the entire file content " + + "which is slow and loses undo history.", + includePartialMessages: true, + abortController: currentAbortController, + hooks: { + PreToolUse: [ + { + matcher: "Edit", + hooks: [ + async (input) => { + console.log("[Phoenix AI] Intercepted Edit tool"); + const myToolId = toolCounter; // capture before any await + const edit = { + file: input.tool_input.file_path, + oldText: input.tool_input.old_string, + newText: input.tool_input.new_string + }; + editCount++; + try { + await nodeConnector.execPeer("applyEditToBuffer", edit); + } catch (err) { + console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); + } + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: myToolId, + edit: edit + }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit applied successfully via Phoenix editor." + } + }; + } + ] + }, + { + matcher: "Read", + hooks: [ + async (input) => { + const filePath = input.tool_input.file_path; + if (!filePath) { + return undefined; + } + try { + const result = await nodeConnector.execPeer("getFileContent", { filePath }); + if (result && result.isDirty && result.content !== null) { + const MAX_LINES = 2000; + const MAX_LINE_LENGTH = 2000; + const lines = result.content.split("\n"); + const offset = input.tool_input.offset || 0; + const limit = input.tool_input.limit || MAX_LINES; + const selected = lines.slice(offset, offset + limit); + let formatted = selected.map((line, i) => { + const truncated = line.length > MAX_LINE_LENGTH + ? line.slice(0, MAX_LINE_LENGTH) + "..." + : line; + return String(offset + i + 1).padStart(6) + "\t" + truncated; + }).join("\n"); + formatted = filePath + " (" + + lines.length + " lines total)\n\n" + formatted; + console.log("[Phoenix AI] Serving dirty file content for:", filePath); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: formatted + } + }; + } + } catch (err) { + console.warn("[Phoenix AI] Failed to check dirty state:", filePath, err.message); + } + return undefined; + } + ] + }, + { + matcher: "Write", + hooks: [ + async (input) => { + console.log("[Phoenix AI] Intercepted Write tool"); + const myToolId = toolCounter; // capture before any await + const edit = { + file: input.tool_input.file_path, + oldText: null, + newText: input.tool_input.content + }; + editCount++; + try { + await nodeConnector.execPeer("applyEditToBuffer", edit); + } catch (err) { + console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); + } + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: myToolId, + edit: edit + }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Write applied successfully via Phoenix editor." + } + }; + } + ] + } + ] + } + }; + + // Set Claude CLI path if found + const claudePath = findGlobalClaudeCli(); + if (claudePath) { + queryOptions.pathToClaudeCodeExecutable = claudePath; + } + + if (model) { + queryOptions.model = model; + } + + // Resume session if we have an existing one (already cleared if sessionAction was "new") + if (currentSessionId) { + queryOptions.resume = currentSessionId; + } + + const _log = (...args) => console.log("[AI]", ...args); + + try { + _log("Query start:", JSON.stringify(prompt).slice(0, 80), "cwd=" + (projectPath || "?")); + + const result = queryFn({ + prompt: prompt, + options: queryOptions + }); + + let accumulatedText = ""; + let lastStreamTime = 0; + + // Tool input tracking + let activeToolName = null; + let activeToolIndex = null; + let activeToolInputJson = ""; + let lastToolStreamTime = 0; + + // Trace counters (logged at tool/query completion, not per-delta) + let toolDeltaCount = 0; + let toolStreamSendCount = 0; + let textDeltaCount = 0; + let textStreamSendCount = 0; + + for await (const message of result) { + // Check abort + if (signal.aborted) { + _log("Aborted"); + break; + } + + // Capture session_id from first message + if (message.session_id && !currentSessionId) { + currentSessionId = message.session_id; + _log("Session:", currentSessionId); + } + + // Handle streaming events + if (message.type === "stream_event") { + const event = message.event; + + // Tool use start — send initial indicator + if (event.type === "content_block_start" && + event.content_block?.type === "tool_use") { + activeToolName = event.content_block.name; + activeToolIndex = event.index; + activeToolInputJson = ""; + toolCounter++; + toolDeltaCount = 0; + toolStreamSendCount = 0; + lastToolStreamTime = 0; + _log("Tool start:", activeToolName, "#" + toolCounter); + nodeConnector.triggerPeer("aiProgress", { + requestId: requestId, + toolName: activeToolName, + toolId: toolCounter, + phase: "tool_use" + }); + } + + // Accumulate tool input JSON and stream preview + if (event.type === "content_block_delta" && + event.delta?.type === "input_json_delta" && + event.index === activeToolIndex) { + activeToolInputJson += event.delta.partial_json; + toolDeltaCount++; + const now = Date.now(); + if (activeToolInputJson && + now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) { + lastToolStreamTime = now; + toolStreamSendCount++; + nodeConnector.triggerPeer("aiToolStream", { + requestId: requestId, + toolId: toolCounter, + toolName: activeToolName, + partialJson: activeToolInputJson + }); + } + } + + // Tool block complete — flush final stream preview and send details + if (event.type === "content_block_stop" && + event.index === activeToolIndex && + activeToolName) { + // Final flush of tool stream (bypasses throttle) + if (activeToolInputJson) { + toolStreamSendCount++; + nodeConnector.triggerPeer("aiToolStream", { + requestId: requestId, + toolId: toolCounter, + toolName: activeToolName, + partialJson: activeToolInputJson + }); + } + let toolInput = {}; + try { + toolInput = JSON.parse(activeToolInputJson); + } catch (e) { + // ignore parse errors + } + _log("Tool done:", activeToolName, "#" + toolCounter, + "deltas=" + toolDeltaCount, "sent=" + toolStreamSendCount, + "json=" + activeToolInputJson.length + "ch"); + nodeConnector.triggerPeer("aiToolInfo", { + requestId: requestId, + toolName: activeToolName, + toolId: toolCounter, + toolInput: toolInput + }); + activeToolName = null; + activeToolIndex = null; + activeToolInputJson = ""; + } + + // Stream text deltas (throttled) + if (event.type === "content_block_delta" && + event.delta?.type === "text_delta") { + accumulatedText += event.delta.text; + textDeltaCount++; + const now = Date.now(); + if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) { + lastStreamTime = now; + textStreamSendCount++; + nodeConnector.triggerPeer("aiTextStream", { + requestId: requestId, + text: accumulatedText + }); + accumulatedText = ""; + } + } + } + } + + // Flush any remaining accumulated text + if (accumulatedText) { + textStreamSendCount++; + nodeConnector.triggerPeer("aiTextStream", { + requestId: requestId, + text: accumulatedText + }); + } + + _log("Complete: tools=" + toolCounter, "edits=" + editCount, + "textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount); + + // Signal completion + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: currentSessionId + }); + + } catch (err) { + const errMsg = err.message || String(err); + const isAbort = signal.aborted || /abort/i.test(errMsg); + + if (isAbort) { + _log("Cancelled"); + // Query was cancelled — clear session so next query starts fresh + currentSessionId = null; + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: null + }); + return; + } + + _log("Error:", errMsg.slice(0, 200)); + + nodeConnector.triggerPeer("aiError", { + requestId: requestId, + error: errMsg + }); + + // Always send aiComplete after aiError so the UI exits streaming state + nodeConnector.triggerPeer("aiComplete", { + requestId: requestId, + sessionId: currentSessionId + }); + } +} diff --git a/src-node/git/processUtils.js b/src-node/git/processUtils.js index e2f3dee2bc..86d760bdc3 100644 --- a/src-node/git/processUtils.js +++ b/src-node/git/processUtils.js @@ -4,8 +4,22 @@ var exec = require("child_process").exec, which = require("which"); var isWin = /^win/.test(process.platform); +var isMac = process.platform === "darwin"; var noop = function () {}; +// Cache for xcode-select CLT check (null = not yet checked) +var _xcodeCliToolsInstalled = null; + +function _isXcodeCliToolsInstalled(callback) { + if (_xcodeCliToolsInstalled !== null) { + return callback(_xcodeCliToolsInstalled); + } + exec("xcode-select -p", function (err) { + _xcodeCliToolsInstalled = !err; + callback(_xcodeCliToolsInstalled); + }); +} + function fixEOL(str) { if (str[str.length - 1] === "\n") { str = str.slice(0, -1); @@ -104,6 +118,17 @@ function executableExists(filename, dir, callback) { var exists = stats.isFile(); if (!exists) { path = undefined; } + // On macOS, /usr/bin/git is a shim that triggers an "Install Xcode CLT" dialog + // when spawned if CLT is not installed. Check for CLT before allowing it. + if (exists && isMac && Path.normalize(path) === "/usr/bin/git") { + return _isXcodeCliToolsInstalled(function (installed) { + if (!installed) { + return callback(null, false, undefined); + } + return callback(null, true, path); + }); + } + return callback(null, exists, path); }); }); diff --git a/src-node/index.js b/src-node/index.js index c3087273f4..95472485af 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -69,6 +69,7 @@ const LivePreview = require("./live-preview"); require("./test-connection"); require("./utils"); require("./git/cli"); +require("./claude-code-agent"); function randomNonce(byteLength) { const randomBuffer = new Uint8Array(byteLength); crypto.getRandomValues(randomBuffer); diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 459799020e..9ba207622a 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,14 +1,15 @@ { "name": "@phcode/node-core", - "version": "5.1.3-0", + "version": "5.1.4-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.3-0", + "version": "5.1.4-0", "license": "GNU-AGPL3.0", "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", "cross-spawn": "^7.0.6", @@ -23,6 +24,26 @@ "node": "24" } }, + "node_modules/@anthropic-ai/claude-code": { + "version": "1.0.128", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.128.tgz", + "integrity": "sha512-uUg5cFMJfeQetQzFw76Vpbro6DAXst2Lpu8aoZWRFSoQVYu5ZSAnbBoxaWmW/IgnHSqIIvtMwzCoqmcA9j9rNQ==", + "license": "SEE LICENSE IN README.md", + "bin": { + "claude": "cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + } + }, "node_modules/@expo/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", @@ -34,6 +55,215 @@ "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", "license": "Apache-2.0" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", diff --git a/src-node/package.json b/src-node/package.json index a397ade5f1..59eb1cf455 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -1,8 +1,8 @@ { "name": "@phcode/node-core", "description": "Phoenix Node Core", - "version": "5.1.3-0", - "apiVersion": "5.1.3", + "version": "5.1.4-0", + "apiVersion": "5.1.4", "keywords": [], "author": "arun@core.ai", "homepage": "https://github.com/phcode-dev/phoenix", @@ -27,6 +27,7 @@ "mime-types": "^2.1.35", "cross-spawn": "^7.0.6", "which": "^2.0.1", - "@expo/sudo-prompt": "^9.3.2" + "@expo/sudo-prompt": "^9.3.2", + "@anthropic-ai/claude-code": "^1.0.0" } } \ No newline at end of file diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 0256043f31..4c03e8fbd8 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -24,7 +24,9 @@ function RemoteFunctions(config = {}) { const MessageBroker = window._Brackets_MessageBroker; // to be used by plugins. const SHARED_STATE = { - __description: "Use this to keep shared state for Live Preview Edit instead of window.*" + __description: "Use this to keep shared state for Live Preview Edit instead of window.*", + _suppressDOMEditDismissal: false, + _suppressDOMEditDismissalTimeout: null }; let _hoverHighlight; @@ -1257,10 +1259,19 @@ function RemoteFunctions(config = {}) { this.rememberedNodes = {}; // this check makes sure that if the element is no more in the DOM then we remove it - if (previouslySelectedElement && !previouslySelectedElement.isConnected) { - dismissUIAndCleanupState(); + // skip this check if suppression is active (e.g., when some internal feature updates source) + if (!SHARED_STATE._suppressDOMEditDismissal) { + if (previouslySelectedElement && !previouslySelectedElement.isConnected) { + dismissUIAndCleanupState(); + } else { + redrawEverything(); + } } else { - redrawEverything(); + // Suppression is active - re-apply outline since attrChange may have wiped it + if (previouslySelectedElement && previouslySelectedElement.isConnected) { + const outlineColor = previouslySelectedElement.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR) ? "#4285F4" : "#3C3F41"; + previouslySelectedElement.style.outline = `1px solid ${outlineColor}`; + } } }; @@ -1335,6 +1346,24 @@ function RemoteFunctions(config = {}) { } } + /** + * Temporarily suppress the DOM edit dismissal check in apply() + * Used when source is modified from UI panels to prevent + * the panel from being dismissed when the DOM is updated. + * @param {Number} durationMs - Duration in milliseconds to suppress (default 100) + */ + function suppressDOMEditDismissal(durationMs) { + durationMs = durationMs || 100; + if (SHARED_STATE._suppressDOMEditDismissalTimeout) { + clearTimeout(SHARED_STATE._suppressDOMEditDismissalTimeout); + } + SHARED_STATE._suppressDOMEditDismissal = true; + SHARED_STATE._suppressDOMEditDismissalTimeout = setTimeout(function() { + SHARED_STATE._suppressDOMEditDismissal = false; + SHARED_STATE._suppressDOMEditDismissalTimeout = null; + }, durationMs); + } + /** * This function dismisses all UI elements and cleans up application state * Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events @@ -1425,7 +1454,8 @@ function RemoteFunctions(config = {}) { "updateConfig": updateConfig, "dismissUIAndCleanupState": dismissUIAndCleanupState, "escapeKeyPressInEditor": _handleEscapeKeyPress, - "getMode": function() { return config.mode; } + "getMode": function() { return config.mode; }, + "suppressDOMEditDismissal": suppressDOMEditDismissal }; // the below code comment is replaced by added scripts for extensibility diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index 93a1ae6740..50f3e9bd90 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -189,15 +189,14 @@ define(function (require, exports, module) { * @param {string} url Absolute URL of the related document */ function _handleRelatedDocumentDeleted(url) { - var liveDoc = _relatedDocuments[url]; + const liveDoc = _relatedDocuments[url]; if (liveDoc) { delete _relatedDocuments[url]; + if (_server) { + _server.remove(liveDoc); + } + _closeDocument(liveDoc); } - - if (_server) { - _server.remove(liveDoc); - } - _closeDocument(liveDoc); } /** @@ -313,12 +312,17 @@ define(function (require, exports, module) { var docPromise = DocumentManager.getDocumentForPath(path); docPromise.done(function (doc) { + // Re-check after async gap: another StylesheetAdded event may have + // already created a live document for this URL while we were waiting. + if (_relatedDocuments[url]) { + return; + } if ((_classForDocument(doc) === LiveCSSDocument) && (!_liveDocument || (doc !== _liveDocument.doc))) { var liveDoc = _createLiveDocument(doc, doc._masterEditor, roots); if (liveDoc) { _server.add(liveDoc); - _relatedDocuments[doc.url] = liveDoc; + _relatedDocuments[url] = liveDoc; liveDoc.on("updateDoc", function (event, url) { var path = _server.urlToPath(url), doc = getLiveDocForPath(path); diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index ae3069b37b..60d4caebf5 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -39,7 +39,6 @@ define(function main(require, exports, module) { LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"), CommandManager = require("command/CommandManager"), PreferencesManager = require("preferences/PreferencesManager"), - StateManager = require("preferences/StateManager"), UrlParams = require("utils/UrlParams").UrlParams, Strings = require("strings"), ExtensionUtils = require("utils/ExtensionUtils"), @@ -57,10 +56,6 @@ define(function main(require, exports, module) { const PREFERENCE_LIVE_PREVIEW_MODE = CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE; - // state manager key to track image gallery selected state, by default we keep this as selected - // if this is true we show the image gallery when an image element is clicked - const IMAGE_GALLERY_STATE = "livePreview.imageGallery.state"; - PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", LIVE_HIGHLIGHT_MODE, { description: StringUtils.format( Strings.LIVE_PREVIEW_MODE_PREFERENCE, LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE), @@ -70,34 +65,11 @@ define(function main(require, exports, module) { _previewModeUpdated(); }); - /** - * get the image gallery state from StateManager - * @returns {boolean} true (default) - */ - function _getImageGalleryState() { - const savedState = StateManager.get(IMAGE_GALLERY_STATE); - return savedState !== undefined && savedState !== null ? savedState : true; - } - - /** - * sets the image gallery state - * @param {Boolean} the state that we need to set - */ - function setImageGalleryState(state) { - StateManager.set(IMAGE_GALLERY_STATE, state); - - // update the config with the new state - const config = MultiBrowserLiveDev.getConfig(); - config.imageGalleryState = state; - MultiBrowserLiveDev.updateConfig(config); - } - let params = new UrlParams(); const defaultConfig = { mode: LIVE_HIGHLIGHT_MODE, // will be updated when we fetch entitlements elemHighlights: CONSTANTS.HIGHLIGHT_HOVER, // default value, this will get updated when the extension loads showRulerLines: false, // default value, this will get updated when the extension loads - imageGalleryState: _getImageGalleryState(), // image gallery selected state isPaidUser: false, // will be updated when we fetch entitlements isLoggedIn: false, // will be updated when we fetch entitlements hasLiveEditCapability: false // handled inside _liveEditCapabilityChanged function @@ -373,7 +345,6 @@ define(function main(require, exports, module) { exports.isActive = isActive; exports.setLivePreviewPinned = setLivePreviewPinned; exports.setLivePreviewTransportBridge = setLivePreviewTransportBridge; - exports.setImageGalleryState = setImageGalleryState; exports.updateElementHighlightConfig = updateElementHighlightConfig; exports.updateRulerLinesConfig = updateRulerLinesConfig; exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds; diff --git a/src/brackets.js b/src/brackets.js index 75aabed6d2..b14f4e1b99 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -126,6 +126,8 @@ define(function (require, exports, module) { require("search/QuickOpenHelper"); require("file/FileUtils"); require("project/SidebarView"); + require("view/SidebarTabs"); + require("core-ai/main"); require("utils/Resizer"); require("LiveDevelopment/main"); require("utils/NodeConnection"); @@ -139,6 +141,7 @@ define(function (require, exports, module) { require("widgets/InlineMenu"); require("thirdparty/tinycolor"); require("utils/LocalizationUtils"); + require("phoenix-builder/main"); // DEPRECATED: In future we want to remove the global CodeMirror, but for now we // expose our required CodeMirror globally so as to avoid breaking extensions in the @@ -290,6 +293,8 @@ define(function (require, exports, module) { WorkspaceManager: require("view/WorkspaceManager"), SearchResultsView: require("search/SearchResultsView"), ScrollTrackMarkers: require("search/ScrollTrackMarkers"), + SidebarTabs: require("view/SidebarTabs"), + SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), doneLoading: false }; @@ -324,7 +329,9 @@ define(function (require, exports, module) { ViewCommandHandlers.restoreFontSize(); ProjectManager.getStartupProjectPath().then((initialProjectPath)=>{ ProjectManager.openProject(initialProjectPath).always(function () { - _initTest(); + if (Phoenix.isTestWindow || window._phoenixBuilder) { + _initTest(); + } // If this is the first launch, and we have an index.html file in the project folder (which should be // the samples folder on first launch), open it automatically. (We explicitly check for the diff --git a/src/command/Commands.js b/src/command/Commands.js index 216561b404..dbd94cb219 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -405,6 +405,9 @@ define(function (require, exports, module) { /** Opens Phoenix Pro page */ exports.HELP_GET_PRO = "help.getPro"; // HelpCommandHandlers.js _handleLinkMenuItem() + /** Cancels Phoenix Pro trial */ + exports.HELP_CANCEL_TRIAL = "help.cancelTrial"; + /** Opens Phoenix License page */ exports.HELP_VIEW_LICENSE = "help.viewLicense"; // HelpCommandHandlers.js _handleLinkMenuItem() diff --git a/src/config.json b/src/config.json index c858953d80..5d502d24c7 100644 --- a/src/config.json +++ b/src/config.json @@ -46,8 +46,8 @@ "bugsnagEnv": "development" }, "name": "Phoenix Code", - "version": "5.1.3-0", - "apiVersion": "5.1.3", + "version": "5.1.4-0", + "apiVersion": "5.1.4", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js new file mode 100644 index 0000000000..c555f517a5 --- /dev/null +++ b/src/core-ai/AIChatPanel.js @@ -0,0 +1,1190 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * AI Chat Panel — renders the chat UI in the AI sidebar tab, handles streaming + * responses from Claude Code, and manages edit application to documents. + */ +define(function (require, exports, module) { + + const SidebarTabs = require("view/SidebarTabs"), + DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + ProjectManager = require("project/ProjectManager"), + FileSystem = require("filesystem/FileSystem"), + SnapshotStore = require("core-ai/AISnapshotStore"), + marked = require("thirdparty/marked.min"); + + let _nodeConnector = null; + let _isStreaming = false; + let _currentRequestId = null; + let _segmentText = ""; // text for the current segment only + let _autoScroll = true; + let _hasReceivedContent = false; // tracks if we've received any text/tool in current response + const _previousContentMap = {}; // filePath → previous content before edit, for undo support + let _currentEdits = []; // edits in current response, for summary card + let _firstEditInResponse = true; // tracks first edit per response for initial PUC + // --- AI event trace logging (compact, non-flooding) --- + let _traceTextChunks = 0; + let _traceToolStreamCounts = {}; // toolId → count + + // DOM references + let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; + + // Live DOM query for $messages — the cached $messages reference can become stale + // after SidebarTabs reparents the panel. Use this for any deferred operations + // (click handlers, callbacks) where the cached reference may no longer be in the DOM. + function _$msgs() { + return $(".ai-chat-messages"); + } + + const PANEL_HTML = + '
' + + '
' + + 'AI Assistant' + + '' + + '
' + + '
' + + '
' + + '' + + 'Thinking...' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
'; + + const UNAVAILABLE_HTML = + '
' + + '
' + + '
' + + '
Claude CLI Not Found
' + + '
' + + 'Install the Claude CLI to use AI features:
' + + 'npm install -g @anthropic-ai/claude-code

' + + 'Then run claude login to authenticate.' + + '
' + + '' + + '
' + + '
'; + + const PLACEHOLDER_HTML = + '
' + + '
' + + '
' + + '
AI Assistant
' + + '
' + + 'AI features require the Phoenix desktop app.' + + '
' + + '
' + + '
'; + + /** + * Initialize the chat panel with a NodeConnector instance. + * @param {Object} nodeConnector - NodeConnector for communicating with the node-side Claude agent. + */ + function init(nodeConnector) { + _nodeConnector = nodeConnector; + + // Wire up events from node side + _nodeConnector.on("aiTextStream", _onTextStream); + _nodeConnector.on("aiProgress", _onProgress); + _nodeConnector.on("aiToolInfo", _onToolInfo); + _nodeConnector.on("aiToolStream", _onToolStream); + _nodeConnector.on("aiToolEdit", _onToolEdit); + _nodeConnector.on("aiError", _onError); + _nodeConnector.on("aiComplete", _onComplete); + + // Check availability and render appropriate UI + _checkAvailability(); + } + + /** + * Show placeholder UI for non-native (browser) builds. + */ + function initPlaceholder() { + const $placeholder = $(PLACEHOLDER_HTML); + SidebarTabs.addToTab("ai", $placeholder); + } + + /** + * Check if Claude CLI is available and render the appropriate UI. + */ + function _checkAvailability() { + _nodeConnector.execPeer("checkAvailability") + .then(function (result) { + if (result.available) { + _renderChatUI(); + } else { + _renderUnavailableUI(result.error); + } + }) + .catch(function (err) { + _renderUnavailableUI(err.message || String(err)); + }); + } + + /** + * Render the full chat UI. + */ + function _renderChatUI() { + $panel = $(PANEL_HTML); + $messages = $panel.find(".ai-chat-messages"); + $status = $panel.find(".ai-chat-status"); + $statusText = $panel.find(".ai-status-text"); + $textarea = $panel.find(".ai-chat-textarea"); + $sendBtn = $panel.find(".ai-send-btn"); + $stopBtn = $panel.find(".ai-stop-btn"); + + // Event handlers + $sendBtn.on("click", _sendMessage); + $stopBtn.on("click", _cancelQuery); + $panel.find(".ai-new-session-btn").on("click", _newSession); + + // Hide "+ New" button initially (no conversation yet) + $panel.find(".ai-new-session-btn").hide(); + + $textarea.on("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + _sendMessage(); + } + if (e.key === "Escape") { + if (_isStreaming) { + _cancelQuery(); + } else { + $textarea.val(""); + } + } + }); + + // Auto-resize textarea + $textarea.on("input", function () { + this.style.height = "auto"; + this.style.height = Math.min(this.scrollHeight, 96) + "px"; // max ~6rem + }); + + // Track scroll position for auto-scroll + $messages.on("scroll", function () { + const el = $messages[0]; + _autoScroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50; + }); + + SidebarTabs.addToTab("ai", $panel); + } + + /** + * Render the unavailable UI (CLI not found). + */ + function _renderUnavailableUI(error) { + const $unavailable = $(UNAVAILABLE_HTML); + $unavailable.find(".ai-retry-btn").on("click", function () { + $unavailable.remove(); + _checkAvailability(); + }); + SidebarTabs.addToTab("ai", $unavailable); + } + + /** + * Send the current input as a message to Claude. + */ + function _sendMessage() { + const text = $textarea.val().trim(); + if (!text || _isStreaming) { + return; + } + + // Show "+ New" button once a conversation starts + $panel.find(".ai-new-session-btn").show(); + + // Append user message + _appendUserMessage(text); + + // Clear input + $textarea.val(""); + $textarea.css("height", "auto"); + + // Set streaming state + _setStreaming(true); + + // Reset segment tracking and show thinking indicator + _segmentText = ""; + _hasReceivedContent = false; + _currentEdits = []; + _firstEditInResponse = true; + _appendThinkingIndicator(); + + // Remove restore highlights from previous interactions + _$msgs().find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + + // Get project path + const projectPath = _getProjectRealPath(); + + _traceTextChunks = 0; + _traceToolStreamCounts = {}; + + const prompt = text; + console.log("[AI UI] Sending prompt:", text.slice(0, 60)); + + _nodeConnector.execPeer("sendPrompt", { + prompt: prompt, + projectPath: projectPath, + sessionAction: "continue" + }).then(function (result) { + _currentRequestId = result.requestId; + console.log("[AI UI] RequestId:", result.requestId); + }).catch(function (err) { + _setStreaming(false); + _appendErrorMessage("Failed to send message: " + (err.message || String(err))); + }); + } + + /** + * Cancel the current streaming query. + */ + function _cancelQuery() { + if (_nodeConnector && _isStreaming) { + _nodeConnector.execPeer("cancelQuery").catch(function () { + // ignore cancel errors + }); + } + } + + /** + * Start a new session: destroy server-side session and clear chat. + */ + function _newSession() { + if (_nodeConnector) { + _nodeConnector.execPeer("destroySession").catch(function () { + // ignore + }); + } + _currentRequestId = null; + _segmentText = ""; + _hasReceivedContent = false; + _isStreaming = false; + _firstEditInResponse = true; + SnapshotStore.reset(); + Object.keys(_previousContentMap).forEach(function (key) { + delete _previousContentMap[key]; + }); + if ($messages) { + $messages.empty(); + } + // Hide "+ New" button since we're back to empty state + if ($panel) { + $panel.find(".ai-new-session-btn").hide(); + } + if ($status) { + $status.removeClass("active"); + } + if ($textarea) { + $textarea.prop("disabled", false); + $textarea[0].focus({ preventScroll: true }); + } + if ($sendBtn) { + $sendBtn.prop("disabled", false); + } + } + + // --- Event handlers for node-side events --- + + function _onTextStream(_event, data) { + _traceTextChunks++; + if (_traceTextChunks === 1) { + console.log("[AI UI]", "First text chunk"); + } + + // Remove thinking indicator on first content + if (!_hasReceivedContent) { + _hasReceivedContent = true; + $messages.find(".ai-thinking").remove(); + } + + // If no active stream target exists, create a new text segment + if (!$messages.find(".ai-stream-target").length) { + _appendAssistantSegment(); + } + + _segmentText += data.text; + _renderAssistantStream(); + } + + // Tool type configuration: icon, color, label + const TOOL_CONFIG = { + Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff", label: "Search files" }, + Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff", label: "Search code" }, + Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: "Read" }, + Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: "Edit" }, + Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: "Write" }, + Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: "Run command" }, + Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: "Skill" } + }; + + function _onProgress(_event, data) { + console.log("[AI UI]", "Progress:", data.phase, data.toolName ? data.toolName + " #" + data.toolId : ""); + if ($statusText) { + const toolName = data.toolName || ""; + const config = TOOL_CONFIG[toolName]; + $statusText.text(config ? config.label + "..." : "Thinking..."); + } + if (data.phase === "tool_use") { + _appendToolIndicator(data.toolName, data.toolId); + } + } + + function _onToolInfo(_event, data) { + const uid = (_currentRequestId || "") + "-" + data.toolId; + const streamCount = _traceToolStreamCounts[uid] || 0; + console.log("[AI UI]", "ToolInfo:", data.toolName, "#" + data.toolId, + "file=" + (data.toolInput && data.toolInput.file_path || "?").split("/").pop(), + "streamEvents=" + streamCount); + _updateToolIndicator(data.toolId, data.toolName, data.toolInput); + } + + function _onToolStream(_event, data) { + const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; + _traceToolStreamCounts[uniqueToolId] = (_traceToolStreamCounts[uniqueToolId] || 0) + 1; + const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]'); + if (!$tool.length) { + return; + } + + // Update label with filename as soon as file_path is available + if (!$tool.data("labelUpdated")) { + const filePath = _extractJsonStringValue(data.partialJson, "file_path"); + if (filePath) { + const fileName = filePath.split("/").pop(); + const config = TOOL_CONFIG[data.toolName] || {}; + $tool.find(".ai-tool-label").text((config.label || data.toolName) + " " + fileName + "..."); + $tool.data("labelUpdated", true); + } + } + + const preview = _extractToolPreview(data.toolName, data.partialJson); + const count = _traceToolStreamCounts[uniqueToolId]; + if (count === 1) { + console.log("[AI UI]", "ToolStream first:", data.toolName, "#" + data.toolId, + "json=" + (data.partialJson || "").length + "ch"); + } + if (preview) { + $tool.find(".ai-tool-preview").text(preview); + _scrollToBottom(); + } + } + + /** + * Extract a complete string value for a given key from partial JSON. + * Returns null if the key isn't found or the value isn't complete yet. + */ + function _extractJsonStringValue(partialJson, key) { + // Try both with and without space after colon: "key":"val" or "key": "val" + let pattern = '"' + key + '":"'; + let idx = partialJson.indexOf(pattern); + if (idx === -1) { + pattern = '"' + key + '": "'; + idx = partialJson.indexOf(pattern); + } + if (idx === -1) { + return null; + } + const start = idx + pattern.length; + // Find the closing quote (not escaped) + let end = start; + while (end < partialJson.length) { + if (partialJson[end] === '"' && partialJson[end - 1] !== '\\') { + return partialJson.slice(start, end).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + end++; + } + return null; // value not complete yet + } + + /** + * Extract a readable one-line preview from partial tool input JSON. + * Looks for the "interesting" key per tool type (e.g. content for Write). + */ + function _extractToolPreview(toolName, partialJson) { + if (!partialJson) { + return ""; + } + // Map tool names to the key whose value we want to preview. + // Tools not listed here get no streaming preview. + const interestingKey = { + Write: "content", + Edit: "new_string", + Bash: "command", + Grep: "pattern", + Glob: "pattern" + }[toolName]; + + if (!interestingKey) { + return ""; + } + + let raw = ""; + // Find the interesting key and grab everything after it + const keyPattern = '"' + interestingKey + '":'; + const idx = partialJson.indexOf(keyPattern); + if (idx !== -1) { + raw = partialJson.slice(idx + keyPattern.length).slice(-120); + } + // If the interesting key hasn't appeared yet, show a byte counter + // so the user sees streaming activity during the file_path phase + if (!raw && partialJson.length > 3) { + return "receiving " + partialJson.length + " bytes..."; + } + if (!raw) { + return ""; + } + // Clean up JSON syntax noise into readable text + let preview = raw + .replace(/\\n/g, " ") + .replace(/\\t/g, " ") + .replace(/\\"/g, '"') + .replace(/\s+/g, " ") + .trim(); + // Strip leading JSON artifacts (quotes, whitespace) + preview = preview.replace(/^[\s"]+/, ""); + // Strip trailing incomplete JSON artifacts + preview = preview.replace(/["{}\[\]]*$/, "").trim(); + return preview; + } + + function _onToolEdit(_event, data) { + const edit = data.edit; + const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId; + console.log("[AI UI]", "ToolEdit:", edit.file.split("/").pop(), "#" + data.toolId); + + // Track for summary card + const oldLines = edit.oldText ? edit.oldText.split("\n").length : 0; + const newLines = edit.newText ? edit.newText.split("\n").length : 0; + _currentEdits.push({ + file: edit.file, + linesAdded: newLines, + linesRemoved: oldLines + }); + + // Capture pre-edit content into pending snapshot and back-fill + const previousContent = _previousContentMap[edit.file]; + const isNewFile = (edit.oldText === null && (previousContent === undefined || previousContent === "")); + SnapshotStore.recordFileBeforeEdit(edit.file, previousContent, isNewFile); + + // On first edit per response, insert initial PUC if needed + if (_firstEditInResponse) { + _firstEditInResponse = false; + if (!SnapshotStore.isInitialSnapshotCreated()) { + const initialIndex = SnapshotStore.createInitialSnapshot(); + // Insert initial restore point PUC before the current tool indicator + const $puc = $( + '
' + + '' + + '
' + ); + $puc.find(".ai-restore-point-btn").on("click", function () { + if (!_isStreaming) { + _onRestoreClick(initialIndex); + } + }); + // Find the last tool indicator and insert the PUC right before it + const $liveMsg = _$msgs(); + const $lastTool = $liveMsg.find(".ai-msg-tool").last(); + if ($lastTool.length) { + $lastTool.before($puc); + } else { + $liveMsg.append($puc); + } + } + } + + // Find the oldest Edit/Write tool indicator for this file that doesn't + // already have edit actions. This is more robust than matching by toolId + // because the SDK with includePartialMessages may re-emit tool_use blocks + // as phantom indicators, causing toolId mismatches. + const fileName = edit.file.split("/").pop(); + const $tool = $messages.find('.ai-msg-tool').filter(function () { + const label = $(this).find(".ai-tool-label").text(); + const hasActions = $(this).find(".ai-tool-edit-actions").length > 0; + return !hasActions && (label.includes("Edit " + fileName) || label.includes("Write " + fileName)); + }).first(); + if (!$tool.length) { + return; + } + + // Remove any existing edit actions (in case of duplicate events) + $tool.find(".ai-tool-edit-actions").remove(); + + // Build the inline edit actions (diff toggle only — undo is on summary card) + const $actions = $('
'); + + // Diff toggle + const $diffToggle = $(''); + const $diff = $('
'); + + if (edit.oldText) { + edit.oldText.split("\n").forEach(function (line) { + $diff.append($('
').text("- " + line)); + }); + edit.newText.split("\n").forEach(function (line) { + $diff.append($('
').text("+ " + line)); + }); + } else { + // Write (new file) — show all as new + edit.newText.split("\n").forEach(function (line) { + $diff.append($('
').text("+ " + line)); + }); + } + + $diffToggle.on("click", function () { + $diff.toggleClass("expanded"); + $diffToggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); + }); + + $actions.append($diffToggle); + $tool.append($actions); + $tool.append($diff); + _scrollToBottom(); + } + + function _onError(_event, data) { + console.log("[AI UI]", "Error:", (data.error || "").slice(0, 200)); + _appendErrorMessage(data.error); + // Don't stop streaming — the node side may continue (partial results) + } + + function _onComplete(_event, data) { + console.log("[AI UI]", "Complete. textChunks=" + _traceTextChunks, + "toolStreams=" + JSON.stringify(_traceToolStreamCounts)); + // Reset trace counters for next query + _traceTextChunks = 0; + _traceToolStreamCounts = {}; + + // Append edit summary if there were edits (finalizeResponse called inside) + if (_currentEdits.length > 0) { + _appendEditSummary(); + } + + _setStreaming(false); + } + + /** + * Append a compact summary card showing all files modified during this response. + */ + function _appendEditSummary() { + // Finalize snapshot and get the after-snapshot index + const afterIndex = SnapshotStore.finalizeResponse(); + + // Aggregate per-file stats + const fileStats = {}; + const fileOrder = []; + _currentEdits.forEach(function (e) { + if (!fileStats[e.file]) { + fileStats[e.file] = { added: 0, removed: 0 }; + fileOrder.push(e.file); + } + fileStats[e.file].added += e.linesAdded; + fileStats[e.file].removed += e.linesRemoved; + }); + + const fileCount = fileOrder.length; + const $summary = $('
'); + const $header = $( + '
' + + '' + + fileCount + (fileCount === 1 ? " file" : " files") + " changed" + + '' + + '
' + ); + + if (afterIndex >= 0) { + // Update any previous summary card buttons to say "Restore to this point" + _$msgs().find('.ai-edit-restore-btn').text("Restore to this point") + .attr("title", "Restore files to this point"); + + // Determine button label: "Undo" if not undone, else "Restore to this point" + const isUndo = !SnapshotStore.isUndoApplied(); + const label = isUndo ? "Undo" : "Restore to this point"; + const title = isUndo ? "Undo changes from this response" : "Restore files to this point"; + + const $restoreBtn = $( + '' + ); + $restoreBtn.on("click", function (e) { + e.stopPropagation(); + if (_isStreaming) { + return; + } + if ($(this).text() === "Undo") { + _onUndoClick(afterIndex); + } else { + _onRestoreClick(afterIndex); + } + }); + $header.append($restoreBtn); + } + $summary.append($header); + + fileOrder.forEach(function (filePath) { + const stats = fileStats[filePath]; + const displayName = filePath.split("/").pop(); + const $file = $( + '
' + + '' + + '' + + '+' + stats.added + '' + + '-' + stats.removed + '' + + '' + + '
' + ); + $file.find(".ai-edit-summary-name").text(displayName); + $file.on("click", function () { + const vfsPath = SnapshotStore.realToVfsPath(filePath); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + }); + $summary.append($file); + }); + + $messages.append($summary); + _scrollToBottom(); + } + + /** + * Handle "Restore to this point" click on any restore point element. + * @param {number} snapshotIndex - index into the snapshots array + */ + function _onRestoreClick(snapshotIndex) { + const $msgs = _$msgs(); + // Remove all existing highlights + $msgs.find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + + // Once any "Restore to this point" is clicked, undo is no longer applicable + SnapshotStore.setUndoApplied(true); + + // Reset all buttons to "Restore to this point" + $msgs.find('.ai-edit-restore-btn').each(function () { + $(this).text("Restore to this point") + .attr("title", "Restore files to this point"); + }); + $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + + SnapshotStore.restoreToSnapshot(snapshotIndex, function (errorCount) { + if (errorCount > 0) { + console.warn("[AI UI] Restore had", errorCount, "errors"); + } + + // Mark the clicked element as "Restored" + const $m = _$msgs(); + const $target = $m.find('[data-snapshot-index="' + snapshotIndex + '"]'); + if ($target.length) { + $target.addClass("ai-restore-highlighted"); + const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); + $btn.text("Restored"); + } + }); + } + + /** + * Handle "Undo" click on the latest summary card. + * @param {number} afterIndex - snapshot index of the latest after-snapshot + */ + function _onUndoClick(afterIndex) { + const $msgs = _$msgs(); + SnapshotStore.setUndoApplied(true); + const targetIndex = afterIndex - 1; + + // Reset all buttons to "Restore to this point" + $msgs.find('.ai-edit-restore-btn').each(function () { + $(this).text("Restore to this point") + .attr("title", "Restore files to this point"); + }); + $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + + SnapshotStore.restoreToSnapshot(targetIndex, function (errorCount) { + if (errorCount > 0) { + console.warn("[AI UI] Undo had", errorCount, "errors"); + } + + // Find the DOM element for the target snapshot and highlight it + const $m = _$msgs(); + const $target = $m.find('[data-snapshot-index="' + targetIndex + '"]'); + if ($target.length) { + $m.find(".ai-restore-highlighted").removeClass("ai-restore-highlighted"); + $target.addClass("ai-restore-highlighted"); + $target[0].scrollIntoView({ behavior: "smooth", block: "center" }); + // Mark the target as "Restored" + const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); + $btn.text("Restored"); + } + }); + } + + // --- DOM helpers --- + + function _appendUserMessage(text) { + const $msg = $( + '
' + + '
You
' + + '
' + + '
' + ); + $msg.find(".ai-msg-content").text(text); + $messages.append($msg); + _scrollToBottom(); + } + + /** + * Append a thinking/typing indicator while waiting for first content. + */ + function _appendThinkingIndicator() { + const $thinking = $( + '
' + + '
Claude
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + ); + $messages.append($thinking); + _scrollToBottom(); + } + + /** + * Append a new assistant text segment. Creates a fresh content block + * that subsequent text deltas will stream into. Shows the "Claude" label + * only for the first segment in a response. + */ + function _appendAssistantSegment() { + // Check if this is a continuation (there's already assistant content or tools above) + const isFirst = !$messages.find(".ai-msg-assistant").not(".ai-thinking").length; + const $msg = $( + '
' + + (isFirst ? '
Claude
' : '') + + '
' + + '
' + ); + $messages.append($msg); + } + + /** + * Re-render the current streaming segment from accumulated segment text. + */ + function _renderAssistantStream() { + const $target = $messages.find(".ai-stream-target").last(); + if ($target.length) { + try { + $target.html(marked.parse(_segmentText, { breaks: true, gfm: true })); + } catch (e) { + $target.text(_segmentText); + } + _scrollToBottom(); + } + } + + function _appendToolIndicator(toolName, toolId) { + // Remove thinking indicator on first content + if (!_hasReceivedContent) { + _hasReceivedContent = true; + $messages.find(".ai-thinking").remove(); + } + + // Finalize the current text segment so tool appears after it, not at the end + $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + _segmentText = ""; + + // Mark any previous active tool indicator as done + _finishActiveTools(); + + const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; + + // Use requestId + toolId to ensure globally unique data-tool-id + const uniqueToolId = (_currentRequestId || "") + "-" + toolId; + const $tool = $( + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + ); + $tool.find(".ai-tool-label").text(config.label + "..."); + $tool.css("--tool-color", config.color); + $tool.attr("data-tool-icon", config.icon); + $messages.append($tool); + _scrollToBottom(); + } + + /** + * Update an existing tool indicator with details once tool input is known. + */ + function _updateToolIndicator(toolId, toolName, toolInput) { + const uniqueToolId = (_currentRequestId || "") + "-" + toolId; + const $tool = $messages.find('.ai-msg-tool[data-tool-id="' + uniqueToolId + '"]'); + if (!$tool.length) { + return; + } + + const config = TOOL_CONFIG[toolName] || { icon: "fa-solid fa-gear", color: "#adb9bd", label: toolName }; + const detail = _getToolDetail(toolName, toolInput); + + // Replace spinner with colored icon immediately + $tool.find(".ai-tool-spinner").replaceWith( + '' + + '' + + '' + ); + + // Update label to include summary + $tool.find(".ai-tool-label").text(detail.summary); + + // Add expandable detail if available + if (detail.lines && detail.lines.length) { + const $detail = $('
'); + detail.lines.forEach(function (line) { + $detail.append($('
').text(line)); + }); + $tool.append($detail); + + // Make header clickable to expand + $tool.find(".ai-tool-header").on("click", function () { + $tool.toggleClass("ai-tool-expanded"); + }).css("cursor", "pointer"); + } + + // For file-related tools, make label clickable to open the file + if (toolInput && toolInput.file_path && + (toolName === "Read" || toolName === "Write" || toolName === "Edit")) { + const filePath = toolInput.file_path; + $tool.find(".ai-tool-label").on("click", function (e) { + e.stopPropagation(); + const vfsPath = SnapshotStore.realToVfsPath(filePath); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + }).css("cursor", "pointer").addClass("ai-tool-label-clickable"); + } + + // Delay marking as done so the streaming preview stays visible briefly. + // The ai-tool-done class hides the preview via CSS; deferring it lets the + // browser paint the preview before it disappears. + setTimeout(function () { + $tool.addClass("ai-tool-done"); + $tool.find(".ai-tool-preview").text(""); + }, 1500); + + _scrollToBottom(); + } + + /** + * Extract a summary and detail lines from tool input. + */ + function _getToolDetail(toolName, input) { + if (!input) { + return { summary: toolName, lines: [] }; + } + switch (toolName) { + case "Glob": + return { + summary: "Searched: " + (input.pattern || ""), + lines: input.path ? ["in " + input.path] : [] + }; + case "Grep": + return { + summary: "Grep: " + (input.pattern || ""), + lines: [input.path ? "in " + input.path : "", input.include ? "include " + input.include : ""] + .filter(Boolean) + }; + case "Read": + return { + summary: "Read " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Edit": + return { + summary: "Edit " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Write": + return { + summary: "Write " + (input.file_path || "").split("/").pop(), + lines: [input.file_path || ""] + }; + case "Bash": + return { + summary: "Ran command", + lines: input.command ? [input.command] : [] + }; + case "Skill": + return { + summary: input.skill ? "Skill: " + input.skill : "Skill", + lines: input.args ? [input.args] : [] + }; + default: + return { summary: toolName, lines: [] }; + } + } + + /** + * Mark all active (non-done) tool indicators as finished. + * Tools that already received _updateToolIndicator (spinner replaced with + * .ai-tool-icon) are skipped — their delayed timeout will add .ai-tool-done. + * This only force-finishes tools that never got a toolInfo (e.g. interrupted). + */ + function _finishActiveTools() { + $messages.find(".ai-msg-tool:not(.ai-tool-done)").each(function () { + const $prev = $(this); + // _updateToolIndicator already ran — let the delayed timeout handle it + if ($prev.find(".ai-tool-icon").length) { + return; + } + $prev.addClass("ai-tool-done"); + const iconClass = $prev.attr("data-tool-icon") || "fa-solid fa-check"; + const color = $prev.css("--tool-color") || "#adb9bd"; + $prev.find(".ai-tool-spinner").replaceWith( + '' + + '' + + '' + ); + }); + } + + + function _appendErrorMessage(text) { + const $msg = $( + '
' + + '
' + + '
' + ); + $msg.find(".ai-msg-content").text(text); + $messages.append($msg); + _scrollToBottom(); + } + + function _setStreaming(streaming) { + _isStreaming = streaming; + if ($status) { + $status.toggleClass("active", streaming); + } + if ($textarea) { + $textarea.prop("disabled", streaming); + $textarea.closest(".ai-chat-input-wrap").toggleClass("disabled", streaming); + if (!streaming) { + $textarea[0].focus({ preventScroll: true }); + } + } + if ($sendBtn && $stopBtn) { + if (streaming) { + $sendBtn.hide(); + $stopBtn.show(); + } else { + $stopBtn.hide(); + $sendBtn.show(); + } + } + // Disable/enable all restore buttons during streaming (use live query) + _$msgs().find(".ai-restore-point-btn, .ai-edit-restore-btn") + .prop("disabled", streaming); + if (!streaming && $messages) { + // Clean up thinking indicator if still present + $messages.find(".ai-thinking").remove(); + + // Finalize: remove ai-stream-target class so future messages get their own container + $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + + // Mark all active tool indicators as done + _finishActiveTools(); + } + } + + function _scrollToBottom() { + if (_autoScroll && $messages && $messages.length) { + const el = $messages[0]; + el.scrollTop = el.scrollHeight; + } + } + + function _escapeAttr(str) { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + } + + // --- Edit application --- + + /** + * Apply a single edit to a document buffer and save to disk. + * Called immediately when Claude's Write/Edit is intercepted, so + * subsequent Reads see the new content both in the buffer and on disk. + * @param {Object} edit - {file, oldText, newText} + * @return {$.Promise} resolves with {previousContent} for undo support + */ + function _applySingleEdit(edit) { + const result = new $.Deferred(); + const vfsPath = SnapshotStore.realToVfsPath(edit.file); + + function _applyToDoc() { + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + const previousContent = doc.getText(); + if (edit.oldText === null) { + // Write (new file or full replacement) + doc.setText(edit.newText); + } else { + // Edit — find oldText and replace + const docText = doc.getText(); + const idx = docText.indexOf(edit.oldText); + if (idx === -1) { + result.reject(new Error("Text not found in file — it may have changed")); + return; + } + const startPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx) : + _indexToPos(docText, idx); + const endPos = doc._masterEditor ? + doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) : + _indexToPos(docText, idx + edit.oldText.length); + doc.replaceRange(edit.newText, startPos, endPos); + } + // Open the file in the editor and save to disk + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + SnapshotStore.saveDocToDisk(doc).always(function () { + result.resolve({ previousContent: previousContent }); + }); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + } + + if (edit.oldText === null) { + // Write — file may not exist yet. Only create on disk if it doesn't + // already exist, to avoid triggering "external change" warnings. + const file = FileSystem.getFileForPath(vfsPath); + file.exists(function (existErr, exists) { + if (exists) { + // File exists — just open and set content, no disk write + _applyToDoc(); + } else { + // New file — create on disk first so getDocumentForPath works + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _applyToDoc(); + }); + } + }); + } else { + // Edit — file must already exist + _applyToDoc(); + } + + return result.promise(); + } + + /** + * Convert a character index in text to a {line, ch} position. + */ + function _indexToPos(text, index) { + let line = 0, ch = 0; + for (let i = 0; i < index; i++) { + if (text[i] === "\n") { + line++; + ch = 0; + } else { + ch++; + } + } + return { line: line, ch: ch }; + } + + // --- Path utilities --- + + /** + * Get the real filesystem path for the current project root. + */ + function _getProjectRealPath() { + const root = ProjectManager.getProjectRoot(); + if (!root) { + return "/"; + } + const fullPath = root.fullPath; + // Desktop (Tauri) paths: /tauri/real/path → /real/path + if (fullPath.startsWith("/tauri/")) { + return fullPath.replace("/tauri", ""); + } + return fullPath; + } + + /** + * Check if a file has unsaved changes in the editor and return its content. + * Used by the node-side Read hook to serve dirty buffer content to Claude. + */ + function getFileContent(params) { + const vfsPath = SnapshotStore.realToVfsPath(params.filePath); + const doc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (doc && doc.isDirty) { + return { isDirty: true, content: doc.getText() }; + } + return { isDirty: false, content: null }; + } + + /** + * Apply an edit to the editor buffer immediately (called by node-side hooks). + * The file appears as a dirty tab so subsequent Reads see the new content. + * @param {Object} params - {file, oldText, newText} + * @return {Promise<{applied: boolean, error?: string}>} + */ + function applyEditToBuffer(params) { + const deferred = new $.Deferred(); + _applySingleEdit(params) + .done(function (result) { + if (result && result.previousContent !== undefined) { + _previousContentMap[params.file] = result.previousContent; + } + deferred.resolve({ applied: true }); + }) + .fail(function (err) { + deferred.resolve({ applied: false, error: err.message || String(err) }); + }); + return deferred.promise(); + } + + // Public API + exports.init = init; + exports.initPlaceholder = initPlaceholder; + exports.getFileContent = getFileContent; + exports.applyEditToBuffer = applyEditToBuffer; +}); diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js new file mode 100644 index 0000000000..d5eb7c2009 --- /dev/null +++ b/src/core-ai/AISnapshotStore.js @@ -0,0 +1,342 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * AI Snapshot Store — content-addressable store and snapshot/restore logic + * for tracking file states across AI responses. Extracted from AIChatPanel + * to separate data/logic concerns from the DOM/UI layer. + */ +define(function (require, exports, module) { + + const DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + FileSystem = require("filesystem/FileSystem"); + + // --- Private state --- + const _contentStore = {}; // hash → content string (content-addressable dedup) + let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null } + let _lastSnapshotAfter = {}; // cumulative state after last completed response + let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null + let _initialSnapshotCreated = false; // has the initial (pre-AI) snapshot been pushed? + let _undoApplied = false; + + // --- Path utility --- + + /** + * Convert a real filesystem path back to a VFS path that Phoenix understands. + */ + function realToVfsPath(realPath) { + // If it already looks like a VFS path, return as-is + if (realPath.startsWith("/tauri/") || realPath.startsWith("/mnt/")) { + return realPath; + } + // Desktop builds use /tauri/ prefix + if (Phoenix.isNativeApp) { + return "/tauri" + realPath; + } + return realPath; + } + + // --- Content-addressable store --- + + function _hashContent(str) { + let h = 0x811c9dc5; // FNV-1a + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); // eslint-disable-line no-bitwise + h = (h * 0x01000193) >>> 0; // eslint-disable-line no-bitwise + } + return h.toString(36); + } + + function storeContent(content) { + const hash = _hashContent(content); + _contentStore[hash] = content; + return hash; + } + + // --- File operations --- + + /** + * Save a document's current content to disk so editors and disk stay in sync. + * @param {Document} doc - Brackets document to save + * @return {$.Promise} + */ + function saveDocToDisk(doc) { + const d = new $.Deferred(); + const file = doc.file; + const content = doc.getText(); + file.write(content, function (err) { + if (err) { + console.error("[AI UI] Save to disk failed:", doc.file.fullPath, err); + d.reject(err); + } else { + doc.notifySaved(); + d.resolve(); + } + }); + return d.promise(); + } + + /** + * Close a document tab (if open) and delete the file from disk. + * Used during restore to remove files that were created by the AI. + * @param {string} filePath - real filesystem path + * @return {$.Promise} + */ + function _closeAndDeleteFile(filePath) { + const result = new $.Deferred(); + const vfsPath = realToVfsPath(filePath); + const file = FileSystem.getFileForPath(vfsPath); + + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + if (openDoc.isDirty) { + openDoc.setText(""); + } + CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) + .always(function () { + file.unlink(function (err) { + if (err) { + result.reject(err); + } else { + result.resolve(); + } + }); + }); + } else { + file.unlink(function (err) { + if (err) { + result.reject(err); + } else { + result.resolve(); + } + }); + } + + return result.promise(); + } + + /** + * Create or update a file with the given content. + * @param {string} filePath - real filesystem path + * @param {string} content - content to set + * @return {$.Promise} + */ + function _createOrUpdateFile(filePath, content) { + const result = new $.Deferred(); + const vfsPath = realToVfsPath(filePath); + + function _setContent() { + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + doc.setText(content); + saveDocToDisk(doc).always(function () { + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + result.resolve(); + }); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + } + + const file = FileSystem.getFileForPath(vfsPath); + file.exists(function (existErr, exists) { + if (exists) { + _setContent(); + } else { + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _setContent(); + }); + } + }); + + return result.promise(); + } + + // --- Snapshot logic --- + + /** + * Apply a snapshot to files. hash=null means delete the file. + * @param {Object} snapshot - { filePath: hash|null } + * @return {$.Promise} resolves with errorCount + */ + function _applySnapshot(snapshot) { + const result = new $.Deferred(); + const filePaths = Object.keys(snapshot); + const promises = []; + let errorCount = 0; + filePaths.forEach(function (fp) { + const hash = snapshot[fp]; + const p = hash === null + ? _closeAndDeleteFile(fp) + : _createOrUpdateFile(fp, _contentStore[hash]); + p.fail(function () { errorCount++; }); + promises.push(p); + }); + if (promises.length === 0) { + return result.resolve(0).promise(); + } + $.when.apply($, promises).always(function () { result.resolve(errorCount); }); + return result.promise(); + } + + // --- Public API --- + + /** + * Record a file's pre-edit state into the pending snapshot and back-fill + * existing snapshots. Called once per file per response (first edit wins). + * @param {string} filePath - real filesystem path + * @param {string} previousContent - content before edit + * @param {boolean} isNewFile - true if the file was created by this edit + */ + function recordFileBeforeEdit(filePath, previousContent, isNewFile) { + if (!_pendingBeforeSnap.hasOwnProperty(filePath)) { + const hash = isNewFile ? null : storeContent(previousContent); + _pendingBeforeSnap[filePath] = hash; + // Back-fill all existing snapshots with this file's pre-AI state + _snapshots.forEach(function (snap) { + if (snap[filePath] === undefined) { + snap[filePath] = hash; + } + }); + // Also back-fill _lastSnapshotAfter + if (_lastSnapshotAfter[filePath] === undefined) { + _lastSnapshotAfter[filePath] = hash; + } + } + } + + /** + * Create the initial snapshot (snapshot 0) capturing file state before any + * AI edits. Called once per session on the first edit. + * @return {number} the snapshot index (always 0) + */ + function createInitialSnapshot() { + const snap = Object.assign({}, _lastSnapshotAfter); + _snapshots.push(snap); + _initialSnapshotCreated = true; + return 0; + } + + /** + * @return {boolean} whether the initial snapshot has been created this session + */ + function isInitialSnapshotCreated() { + return _initialSnapshotCreated; + } + + /** + * Finalize snapshot state when a response completes. + * Builds an "after" snapshot from current document content for edited files, + * pushes it, and resets transient tracking variables. + * @return {number} the after-snapshot index, or -1 if no edits happened + */ + function finalizeResponse() { + let afterIndex = -1; + if (Object.keys(_pendingBeforeSnap).length > 0) { + // Build "after" snapshot = current _lastSnapshotAfter + current content of edited files + const afterSnap = Object.assign({}, _lastSnapshotAfter); + Object.keys(_pendingBeforeSnap).forEach(function (fp) { + const vfsPath = realToVfsPath(fp); + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + afterSnap[fp] = storeContent(openDoc.getText()); + } + }); + _snapshots.push(afterSnap); + _lastSnapshotAfter = afterSnap; + afterIndex = _snapshots.length - 1; + } + _pendingBeforeSnap = {}; + _undoApplied = false; + return afterIndex; + } + + /** + * Restore files to the state captured in a specific snapshot. + * @param {number} index - index into _snapshots + * @param {Function} onComplete - callback(errorCount) + */ + function restoreToSnapshot(index, onComplete) { + if (index < 0 || index >= _snapshots.length) { + onComplete(0); + return; + } + _applySnapshot(_snapshots[index]).done(function (errorCount) { + onComplete(errorCount); + }); + } + + /** + * @return {boolean} whether undo has been applied (latest summary clicked) + */ + function isUndoApplied() { + return _undoApplied; + } + + /** + * @param {boolean} val + */ + function setUndoApplied(val) { + _undoApplied = val; + } + + /** + * @return {number} number of snapshots + */ + function getSnapshotCount() { + return _snapshots.length; + } + + /** + * Clear all snapshot state. Called when starting a new session. + */ + function reset() { + Object.keys(_contentStore).forEach(function (k) { delete _contentStore[k]; }); + _snapshots = []; + _lastSnapshotAfter = {}; + _pendingBeforeSnap = {}; + _initialSnapshotCreated = false; + _undoApplied = false; + } + + exports.realToVfsPath = realToVfsPath; + exports.saveDocToDisk = saveDocToDisk; + exports.storeContent = storeContent; + exports.recordFileBeforeEdit = recordFileBeforeEdit; + exports.createInitialSnapshot = createInitialSnapshot; + exports.isInitialSnapshotCreated = isInitialSnapshotCreated; + exports.finalizeResponse = finalizeResponse; + exports.restoreToSnapshot = restoreToSnapshot; + exports.isUndoApplied = isUndoApplied; + exports.setUndoApplied = setUndoApplied; + exports.getSnapshotCount = getSnapshotCount; + exports.reset = reset; +}); diff --git a/src/core-ai/editApplyVerification.md b/src/core-ai/editApplyVerification.md new file mode 100644 index 0000000000..f8891953b8 --- /dev/null +++ b/src/core-ai/editApplyVerification.md @@ -0,0 +1,164 @@ +# Edit Apply & Restore Point Verification + +Formal verification cases for the timeline-of-restore-points UX in AIChatPanel. + +## UX Model + +- **Initial PUC** (snapshot 0): appears once per session before the first edit tool indicator. Always shows "Restore to this point". +- **Summary card** (latest): shows "Undo" (when `_undoApplied` is false) or "Restore to this point" (when `_undoApplied` is true). +- **Summary card** (not latest): always shows "Restore to this point". +- After any restore/undo is clicked, `_undoApplied = true` and ALL buttons become "Restore to this point" until the next AI response creates new edits. +- The clicked restore point shows **"Restored"** text with green highlight styling. Clicking a different restore point moves the "Restored" indicator to that one. +- All restore/undo buttons are **disabled during AI streaming** and re-enabled when the response completes. + +## Snapshot List (flat, one per restore point) + +``` +_snapshots[0] = initial state (original files, before any AI edits) +_snapshots[1] = after R1 edits +_snapshots[2] = after R2 edits +... +``` + +## State Variables + +- `_snapshots[]`: flat array of `{ filePath: hash|null }` snapshots +- `_lastSnapshotAfter`: cumulative state after last completed response +- `_pendingBeforeSnap`: per-file pre-edit tracking during current response +- `_initialSnapshotCreated`: whether snapshot 0 has been pushed +- `_undoApplied`: whether undo/restore has been clicked on any card + +## DOM Layout Example + +``` +[User: "fix bugs"] +[── Restore to this point ──] <- initial PUC (snapshot 0), session-first only +[Claude: "I'll fix..."] +[Edit file1.js] +[Edit file2.js] +[Summary: 2 files changed | Undo] <- snapshot 1 + +[User: "also refactor"] +[Claude: "Refactoring..."] +[Edit file1.js] +[Summary: 1 file changed | Undo] <- snapshot 2 (snapshot 1 becomes "Restore to this point") +``` + +## Key API Methods + +### AISnapshotStore + +- `recordFileBeforeEdit(filePath, previousContent, isNewFile)`: tracks pre-edit state, back-fills all existing snapshots +- `createInitialSnapshot()`: pushes snapshot 0 from `_lastSnapshotAfter`, returns index 0 +- `isInitialSnapshotCreated()`: returns whether snapshot 0 exists +- `finalizeResponse()`: builds after-snapshot from current doc content, pushes it, resets `_undoApplied`, returns index (or -1) +- `restoreToSnapshot(index, callback)`: applies `_snapshots[index]` to files, calls `callback(errorCount)` +- `isUndoApplied()` / `setUndoApplied(val)`: getter/setter for undo state +- `reset()`: clears all state for new session + +### AIChatPanel + +- `_$msgs()`: live DOM query helper — returns `$(".ai-chat-messages")` to avoid stale cached `$messages` reference (see Implementation Notes) +- `_onToolEdit()`: on first edit per response, inserts initial PUC if not yet created. Diff toggle only (no per-edit undo). +- `_appendEditSummary()`: calls `finalizeResponse()`, creates summary card with "Undo" or "Restore to this point" button +- `_onUndoClick(afterIndex)`: sets `_undoApplied`, resets all buttons to "Restore to this point", restores to `afterIndex - 1`, highlights target element as "Restored", scrolls to it +- `_onRestoreClick(snapshotIndex)`: sets `_undoApplied`, resets all buttons to "Restore to this point", restores to the given snapshot, marks clicked element as "Restored" +- `_setStreaming(streaming)`: disables/enables all restore buttons during AI streaming + +## Verification Cases + +### Case 1: Single response editing 2 files — Undo then Restore +- R1 edits A: "v0" -> "v1", edits B: "b0" -> "b1" +- Snapshots: [0: {A:v0, B:b0}], [1: {A:v1, B:b1}] +- Initial PUC appears (snapshot 0), summary card shows "Undo" (snapshot 1) +- Click "Undo" on summary -> files revert to snapshot 0 (A=v0, B=b0) +- Scroll to initial PUC, highlighted green, button says "Restored". Summary says "Restore to this point" +- Click "Restore to this point" on summary (snapshot 1) -> files forward to A=v1, B=b1 +- Summary now says "Restored", initial PUC says "Restore to this point" + +### Case 2: Two responses — Undo latest +- R1: A "v0"->"v1", R2: A "v1"->"v2" +- Snapshots: [0: {A:v0}], [1: {A:v1}], [2: {A:v2}] +- Card 1 shows "Restore to this point", card 2 shows "Undo" +- Click "Undo" on card 2 -> A="v1" (snapshot 1), card 1 highlighted with "Restored" +- All other buttons become "Restore to this point" + +### Case 3: Two responses — Restore to initial +- Same setup as Case 2 +- Click "Restore to this point" on initial PUC (snapshot 0) -> A="v0" +- Initial PUC shows "Restored", all others show "Restore to this point" + +### Case 4: Restore to middle point +- R1: A "v0"->"v1", R2: A "v1"->"v2", R3: A "v2"->"v3" +- Snapshots: [0: {A:v0}], [1: {A:v1}], [2: {A:v2}], [3: {A:v3}] +- Click "Restore to this point" on card 1 (snapshot 1) -> A="v1", card 1 shows "Restored" +- Click "Restore to this point" on card 2 (snapshot 2) -> A="v2", card 2 shows "Restored", card 1 back to "Restore to this point" +- Click "Restore to this point" on initial PUC (snapshot 0) -> A="v0" + +### Case 5: Two responses editing different files +- R1: A "a0"->"a1", R2: B "b0"->"b1" +- Snapshots: [0: {A:a0}], [1: {A:a1}], [2: {A:a1, B:b1}] +- Back-fill: when B is first seen in R2, snapshot 0 and 1 get B:b0 added +- Click initial PUC (snapshot 0) -> A=a0, B=b0 +- Click card 1 (snapshot 1) -> A=a1, B=b0 (B not yet edited) +- Click card 2 (snapshot 2) -> A=a1, B=b1 + +### Case 6: File created by R1, edited by R2 +- R1 creates A (null -> "new"), R2 edits A: "new"->"edited" +- Snapshots: [0: {A:null}], [1: {A:new}], [2: {A:edited}] +- Click initial PUC (snapshot 0) -> A deleted (hash=null) +- Click card 1 (snapshot 1) -> A="new" (re-created) +- Click card 2 (snapshot 2) -> A="edited" + +### Case 7: File created by R2 +- R1 edits A, R2 creates B +- Snapshots: [0: {A:a0}], [1: {A:a1}], [2: {A:a1, B:new}] +- Back-fill: snapshot 0 and 1 get B:null +- Click initial PUC (snapshot 0) -> A=a0, B deleted +- Click card 1 (snapshot 1) -> A=a1, B deleted (null) +- Click card 2 (snapshot 2) -> A=a1, B=new + +### Case 8: Undo resets on next AI response +- R1 edits A. Click "Undo" -> `_undoApplied = true`, all buttons "Restore to this point" +- User sends new message, R2 edits B +- `_undoApplied` resets to false via `finalizeResponse()` +- New summary card shows "Undo", previous cards show "Restore to this point" + +### Case 9: Response with no edits +- R1 only reads files, no edits +- No initial PUC inserted, no summary card, no restore buttons + +### Case 10: Cancelled partial response +- `_onComplete` fires, `_appendEditSummary()` calls `finalizeResponse()` with partial edits. Works identically to a complete response. + +### Case 11: Buttons disabled during streaming +- User sends message, AI starts streaming with edits +- Initial PUC and all summary card buttons have `disabled` attribute set +- Clicking them does nothing (`_isStreaming` guard in handlers) +- When streaming completes, `_setStreaming(false)` re-enables all buttons + +## Implementation Notes + +### Stale `$messages` reference +The cached `$messages` jQuery variable (set in `_renderChatUI()`) can become stale after `SidebarTabs.addToTab()` reparents the panel. DOM queries via the stale reference silently fail — mutations apply to a detached node instead of the visible DOM. + +**Fix**: `_$msgs()` helper returns `$(".ai-chat-messages")` (live DOM query). Used in all deferred operations: `_onRestoreClick`, `_onUndoClick`, `_setStreaming` (button disable/enable), `_sendMessage` (highlight removal), `_appendEditSummary` (previous button update), and PUC insertion in `_onToolEdit`. + +The cached `$messages` is still used for synchronous operations during rendering (appending messages, streaming updates) where it remains valid. + +## Manual Testing Plan + +1. Reload Phoenix, open AI tab +2. Ask Claude to edit a file (two changes) +3. Verify initial PUC appears before the first Edit tool indicator +4. Verify summary card with "Undo" button appears after response completes +5. Verify all restore buttons are disabled during streaming, enabled after +6. Click "Undo" -> verify file reverts, scroll to initial PUC, highlighted green with "Restored" text +7. Verify all other buttons now show "Restore to this point" +8. Click "Restore to this point" on summary card -> verify file returns to edited state, summary shows "Restored", PUC shows "Restore to this point" +9. Ask Claude to make another edit (second response) +10. Verify first summary card says "Restore to this point", second says "Undo" +11. Click "Undo" on second -> verify files revert to state after first response, first card highlighted with "Restored" +12. Click "Restore to this point" on any card -> verify files match that snapshot, clicked card shows "Restored" +13. Ask Claude a question (no edits) -> verify no PUC or restore buttons appear +14. Start new session -> verify all state cleared diff --git a/src/core-ai/main.js b/src/core-ai/main.js new file mode 100644 index 0000000000..01ffb04927 --- /dev/null +++ b/src/core-ai/main.js @@ -0,0 +1,54 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * AI sidebar tab integration. Sets up a NodeConnector to the claude-code-agent + * running in the node process and initializes the AIChatPanel UI. + * + * In non-native (browser) builds, shows a placeholder message instead. + */ +define(function (require, exports, module) { + + var AppInit = require("utils/AppInit"), + SidebarTabs = require("view/SidebarTabs"), + NodeConnector = require("NodeConnector"), + AIChatPanel = require("core-ai/AIChatPanel"); + + var AI_CONNECTOR_ID = "ph_ai_claude"; + + exports.getFileContent = async function (params) { + return AIChatPanel.getFileContent(params); + }; + + exports.applyEditToBuffer = async function (params) { + return AIChatPanel.applyEditToBuffer(params); + }; + + AppInit.appReady(function () { + SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); + + if (Phoenix.isNativeApp) { + var nodeConnector = NodeConnector.createNodeConnector(AI_CONNECTOR_ID, exports); + AIChatPanel.init(nodeConnector); + } else { + AIChatPanel.initPlaceholder(); + } + }); +}); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 428d0c9afe..387089dfed 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -60,6 +60,7 @@ define(function (require, exports, module) { NodeConnector = require("NodeConnector"), NodeUtils = require("utils/NodeUtils"), ChangeHelper = require("editor/EditorHelper/ChangeHelper"), + SidebarTabs = require("view/SidebarTabs"), _ = require("thirdparty/lodash"); const KernalModeTrust = window.KernalModeTrust; @@ -1955,6 +1956,7 @@ define(function (require, exports, module) { function handleShowInTree() { let activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if(activeFile){ + SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); ProjectManager.showInTree(activeFile); } } diff --git a/src/editor/EditorManager.js b/src/editor/EditorManager.js index 40f9d30092..4dd00f2807 100644 --- a/src/editor/EditorManager.js +++ b/src/editor/EditorManager.js @@ -775,7 +775,7 @@ define(function (require, exports, module) { // Set up event dispatching EventDispatcher.makeEventDispatcher(exports); - EventDispatcher.setLeakThresholdForEvent(EVENT_ACTIVE_EDITOR_CHANGED, 30); + EventDispatcher.setLeakThresholdForEvent(EVENT_ACTIVE_EDITOR_CHANGED, 50); // File-based preferences handling exports.on(EVENT_ACTIVE_EDITOR_CHANGED, function (e, current) { diff --git a/src/extensions/default/DebugCommands/main.js b/src/extensions/default/DebugCommands/main.js index e2a5b97824..58d792abd8 100644 --- a/src/extensions/default/DebugCommands/main.js +++ b/src/extensions/default/DebugCommands/main.js @@ -19,7 +19,7 @@ * */ -/*globals path, logger, Phoenix*/ +/*globals path, logger, Phoenix, AppConfig*/ /*jslint regexp: true */ define(function (require, exports, module) { @@ -828,6 +828,9 @@ define(function (require, exports, module) { diagnosticsSubmenu.addMenuItem(DEBUG_RUN_UNIT_TESTS); CommandManager.register(Strings.CMD_BUILD_TESTS, DEBUG_BUILD_TESTS, TestBuilder.toggleTestBuilder); diagnosticsSubmenu.addMenuItem(DEBUG_BUILD_TESTS); + if (AppConfig.config.environment === "dev") { + diagnosticsSubmenu.addMenuItem("debug.phoenixBuilderConnect"); + } diagnosticsSubmenu.addMenuDivider(); diagnosticsSubmenu.addMenuItem(DEBUG_ENABLE_LOGGING); diagnosticsSubmenu.addMenuItem(DEBUG_ENABLE_PHNODE_INSPECTOR, undefined, undefined, undefined, { diff --git a/src/extensions/default/Git/src/git/GitCli.js b/src/extensions/default/Git/src/git/GitCli.js index 39cd9aa56f..16d269776e 100644 --- a/src/extensions/default/Git/src/git/GitCli.js +++ b/src/extensions/default/Git/src/git/GitCli.js @@ -1022,9 +1022,33 @@ define(function (require, exports) { function getGitRoot() { var projectRoot = Utils.getProjectRoot(); - return git(["rev-parse", "--show-toplevel"], { - cwd: fs.getTauriPlatformPath(projectRoot) - }) + + // Quick filesystem pre-check: if .git doesn't exist in the project root, + // skip spawning git entirely. This avoids triggering macOS CLT shim dialogs + // on non-git projects and is a minor optimization on all platforms. + return new Promise(function (resolve) { + var checkPath = projectRoot; + if (strEndsWith(checkPath, "/")) { + checkPath = checkPath.slice(0, -1); + } + if (typeof brackets !== "undefined" && brackets.fs && brackets.fs.stat) { + brackets.fs.stat(checkPath + "/.git", function (err, result) { + var exists = err ? false : (result.isFile() || result.isDirectory()); + resolve(exists); + }); + } else { + FileSystem.resolve(checkPath + "/.git", function (err, item, stat) { + var exists = err ? false : (stat.isFile || stat.isDirectory); + resolve(exists); + }); + } + }).then(function (hasGitDir) { + if (!hasGitDir) { + return null; + } + return git(["rev-parse", "--show-toplevel"], { + cwd: fs.getTauriPlatformPath(projectRoot) + }) .catch(function (e) { if (ErrorHandler.contains(e, "Not a git repository")) { return null; @@ -1095,6 +1119,7 @@ define(function (require, exports) { }); }); + }); } function setTagName(tagname, commitHash) { diff --git a/src/extensions/default/Git/src/utils/Setup.js b/src/extensions/default/Git/src/utils/Setup.js index af109c30b9..a172da134c 100644 --- a/src/extensions/default/Git/src/utils/Setup.js +++ b/src/extensions/default/Git/src/utils/Setup.js @@ -16,9 +16,10 @@ define(function (require, exports) { ]; let standardGitPathsNonWin = [ + "/opt/homebrew/bin/git", // Apple Silicon Homebrew "/usr/local/git/bin/git", "/usr/local/bin/git", - "/usr/bin/git" + "/usr/bin/git" // macOS CLT shim check handled on node side ]; let extensionActivated = false; @@ -27,7 +28,7 @@ define(function (require, exports) { function getGitVersion() { return new Promise(function (resolve, reject) { - // TODO: do this in two steps - first check user config and then check all + // User-configured path gets priority, then "git" (PATH lookup), then standard paths var pathsToLook = [Preferences.get("gitPath"), "git"].concat(brackets.platform === "win" ? standardGitPathsWin : standardGitPathsNonWin); pathsToLook = _.unique(_.compact(pathsToLook)); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css index ec9f7cef78..6e3536afd7 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css +++ b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css @@ -105,6 +105,51 @@ padding-left: 7.5px; } +.lp-device-size-icon { + color: #a0a0a0; + display: flex; + align-items: center; + padding-left: 7.5px; + margin-right: 7.5px; +} + +#deviceSizeBtn.btn-dropdown::after { + position: static; + margin-top: 2px; + margin-left: 3px; +} + +.device-size-item-icon { + margin-right: 6px; + width: 12px; + text-align: center; + font-size: inherit; +} + +.device-size-item-row { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.device-size-item-width { + margin-left: 10px; + opacity: 0.5; +} + +.device-size-item-disabled { + opacity: 0.35; +} + +.device-size-item-breakpoint-icon { + margin-right: 6px; + width: 12px; + text-align: center; + font-size: inherit; + color: rgba(100, 180, 255, 0.8); +} + #livePreviewModeBtn { min-width: fit-content; display: flex; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index b28c84eaa4..1c393d0ef6 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -545,7 +545,8 @@ define(function (require, exports, module) { let panel, urlPinned, currentLivePreviewURL = "", - currentPreviewFile = ''; + currentPreviewFile = '', + _loadGeneration = 0; function _blankIframe() { // we have to remove the dom node altog as at time chrome fails to clear workers if we just change @@ -795,8 +796,12 @@ define(function (require, exports, module) { if(!isPreviewLoadable){ return; } + const thisGeneration = ++_loadGeneration; // panel-live-preview-title let previewDetails = await StaticServer.getPreviewDetails(); + if(thisGeneration !== _loadGeneration) { + return; // A newer _loadPreview call has been made; this one is stale + } if(urlPinned && !force) { return; } diff --git a/src/index.html b/src/index.html index a4e5c3cb90..0c20244df5 100644 --- a/src/index.html +++ b/src/index.html @@ -27,7 +27,7 @@ + +