diff --git a/.agent/plans/ecode_user_defined_snippets_plan.md b/.agent/plans/ecode_user_defined_snippets_plan.md index 3909d6285..2ce16fe6f 100644 --- a/.agent/plans/ecode_user_defined_snippets_plan.md +++ b/.agent/plans/ecode_user_defined_snippets_plan.md @@ -1,6 +1,6 @@ # ecode User-Defined Snippets Implementation Plan -Status: **Phase 1 implemented and manually validated. Phase 2 not started. Phase 3 optional.** +Status: **Phase 1 and Phase 2 implemented and validated. Phase 3 optional.** Last status review: 2026-07-29. @@ -43,14 +43,15 @@ Phase 1 hardening that remains useful but is not blocking current use: - keep the new user-facing `docs/snippets.md` guide current as Phase 2 capabilities are added; - run the full manual matrix from Section 17.4 against more real-world snippet collections. -Everything in Phase 2 is now a product choice rather than a prerequisite for useful snippet -support. The most independently useful candidates are: +The Phase 2 product scope has now been selected: -1. an **Insert Snippet** searchable command; -2. a **Configure Snippets** command that creates/opens the current language file; -3. optional exact-prefix Tab expansion when no popup or active snippet session exists; -4. `include` / `exclude` file-pattern scopes; -5. additional workspace, clipboard, cursor, date/time, random, UUID, and comment variables. +1. add an **Insert Snippet** Universal Locator provider and the plugin-facing provider lifecycle + APIs it requires; +2. add `include` / `exclude` file-pattern scopes; +3. add the complete pending VS Code variable set and casing modifiers listed in Section 13; +4. defer **Configure Snippets** until the feature has broader real-world use; +5. do not add exact-prefix Tab expansion because accidental activation is not worth the marginal + convenience over autocomplete and the locator. Phase 3 remains explicitly optional and should be driven only by observed compatibility needs. @@ -532,7 +533,7 @@ Phase 1 must preserve the currently supported variables: - `TM_DIRECTORY` - `TM_FILEPATH` -Phase 2 should add the high-value, straightforward variables: +Phase 2 will add the remaining documented VS Code variables: - `RELATIVE_FILEPATH` - `WORKSPACE_NAME` @@ -540,13 +541,33 @@ Phase 2 should add the high-value, straightforward variables: - `CLIPBOARD` - `CURSOR_INDEX` - `CURSOR_NUMBER` -- current date/time variables; -- `RANDOM`, `RANDOM_HEX`, and `UUID` if suitable engine utilities already exist; -- `LINE_COMMENT`, `BLOCK_COMMENT_START`, and `BLOCK_COMMENT_END` when syntax definitions expose - reliable comment delimiters. +- `CURRENT_YEAR` +- `CURRENT_YEAR_SHORT` +- `CURRENT_MONTH` +- `CURRENT_MONTH_NAME` +- `CURRENT_MONTH_NAME_SHORT` +- `CURRENT_DATE` +- `CURRENT_DAY_NAME` +- `CURRENT_DAY_NAME_SHORT` +- `CURRENT_HOUR` +- `CURRENT_MINUTE` +- `CURRENT_SECOND` +- `CURRENT_MILLISECOND` +- `CURRENT_SECONDS_UNIX` +- `CURRENT_MILLISECONDS_UNIX` +- `CURRENT_TIMEZONE_OFFSET` +- `CURRENT_TIMEZONE_NAME` +- `RANDOM` +- `RANDOM_HEX` +- `UUID` +- `LINE_COMMENT` +- `BLOCK_COMMENT_START` +- `BLOCK_COMMENT_END` -Do not create new time, random, UUID, clipboard, or syntax-comment infrastructure solely for -snippets. Use existing services or defer the variable. +Use existing ecode/eepp workspace, clipboard, time, random, UUID, and syntax-definition services. +Keep all variable construction in the shared provider so LSP and user snippets behave identically. +Phase 2 also includes the `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` transform format +modifiers. ## 14. Phase 1 - Core user-defined snippets @@ -616,13 +637,24 @@ follow-ups, not blockers for the currently working feature. Phase 2 is part of the intended feature, but should be implemented after Phase 1 is usable and has been tested with real snippet collections. -**Phase status: not started and no longer required for the initial release.** Each subsection can -be accepted or rejected independently based on whether the workflow is valuable to ecode users. +**Phase status: implemented and validated.** Insert Snippet, file-pattern scopes, and the complete +pending variable/modifier set are available. Configure Snippets is deferred and exact-prefix Tab +expansion is rejected. ### 15.1 Dedicated Insert Snippet command -Add an `insert-snippet` editor/application command that opens a searchable list of snippets valid -for the current language and file. +Implement snippet browsing as a new Universal Locator provider containing snippets valid for the +current language and file. Do not create another popup or a parallel searchable-list UI. + +Required locator infrastructure: + +- expose the Universal Locator from `PluginContext`; +- add public APIs to register and unregister locator providers at runtime; +- make registration ownership/lifetime explicit so plugins can unregister safely during disable, + reload, and destruction; +- ensure provider removal cannot leave stale locator entries or callbacks; +- have `AutoCompletePlugin` register its snippet provider when enabled and unregister it on every + shutdown/reload path. The list should show: @@ -631,38 +663,24 @@ The list should show: - description; - source when needed to disambiguate duplicates. -Picking an entry inserts it without requiring a typed prefix. Reuse existing list/model helpers or -the Universal Locator where that produces a natural ecode interaction. Do not build a second -autocomplete popup implementation. +Picking an entry inserts it without requiring a typed prefix and without deleting prefix text. +Reuse the existing snippet insertion path, including variables, indentation, multiple cursors, +choices, and tab-stop sessions. Add a configurable keybinding entry, but no default binding is required if the command is readily available from the command palette. -### 15.2 Optional exact-prefix Tab completion +### 15.2 Exact-prefix Tab completion - rejected -Add an autocomplete setting such as: - -```json -"snippets": { - "enabled": true, - "tab_completion": false -} -``` - -When enabled, key handling order must be: - -1. active snippet session: navigate to the next tab stop; -2. visible completion popup: accept the selected suggestion; -3. no popup and exact snippet prefix before the cursor: expand it; -4. otherwise: allow the editor's normal Tab command. - -If multiple definitions have the same exact prefix, open a choice list instead of selecting one -arbitrarily. Shift+Tab must never start a new snippet expansion. - -Use command/keybinding resolution rather than hard-coded key codes. +Do not expand a snippet merely because Tab is pressed after an exact prefix. This can trigger +accidentally and provides little value once snippets are available through normal autocomplete and +the Universal Locator. Tab remains responsible for accepting a visible completion, navigating an +active snippet session, or performing its normal editor action. ### 15.3 Configure Snippets command +**Decision: deferred until user adoption justifies it.** + Add a command that opens or creates the appropriate user snippet file for the current language. Creation should use a small commented starter document valid as JSONC. Do not overwrite an existing file. @@ -672,6 +690,8 @@ is the priority. ### 15.4 File pattern scopes +**Decision: accepted for Phase 2.** + Implement `include` and `exclude` using the engine's existing glob/path matching facilities. - Filename-only patterns match the filename. @@ -685,19 +705,23 @@ keystroke. ### 15.5 Common variables and modifiers +**Decision: accepted for Phase 2, including the complete list in Section 13.** + - Add variables listed in Section 13 when supported by existing ecode/eepp services. - Add `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` transform format modifiers. - Add tests for Unicode behavior where the chosen string helpers define it clearly. ### 15.6 Phase 2 acceptance criteria -- Users can browse and insert snippets without typing a prefix. -- Optional Tab completion does not break indentation or active snippet navigation. -- Duplicate exact prefixes prompt for a choice. -- `include` and `exclude` filter snippets predictably. -- Common workspace, clipboard, cursor, time, and casing transformations behave as documented where - supported. -- Users can locate/create their language snippet file from ecode without learning platform paths. +- Plugins can dynamically register and unregister Universal Locator providers through + `PluginContext` without stale callbacks during disable/reload. +- Users can browse and insert applicable snippets without typing or remembering a prefix. +- Duplicate names/prefixes remain distinguishable by label, description, and source. +- `include` and `exclude` filter snippets predictably using existing glob support. +- All variables listed in Section 13 behave consistently for LSP and user snippets, including + independent cursor values during multi-cursor insertion. +- `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` modifiers behave as documented. +- User documentation and the VS Code comparison are updated with the new behavior. ## 16. Phase 3 - Optional compatibility work @@ -835,10 +859,10 @@ suggestions. Avoid per-frame or draw-time snippet work entirely. ## 20. Documentation -**Status: complete for Phase 1.** The ecode documentation repository contains `docs/snippets.md`, -linked from `docs/autocomplete.md`, with locations, examples, supported syntax and variables, -runtime behavior, troubleshooting, and a detailed VS Code compatibility comparison. Update that -document alongside each Phase 2 compatibility change. +**Status: complete for Phase 1 and Phase 2.** The ecode documentation repository contains +`docs/snippets.md`, linked from `docs/autocomplete.md`, with locations, examples, supported syntax +and variables, runtime behavior, troubleshooting, the Insert Snippet locator, file-pattern scopes, +and a detailed VS Code compatibility comparison. Ship a concise user-facing document or configuration section containing: @@ -869,14 +893,16 @@ Implement in this order to keep each change reviewable and testable: 8. Add user/project discovery and initial asynchronous loading. 9. Add workspace changes, filesystem reload, last-known-good behavior, and stale-job protection. 10. Complete Phase 1 acceptance testing and release it for real-world snippet-pack testing. -11. Add the Insert Snippet and Configure Snippets commands. -12. Add optional exact-prefix Tab completion. -13. Add file-pattern filters and high-value variables/modifiers. -14. Reassess optional Phase 3 only from observed incompatibilities. +11. Expose the Universal Locator through `PluginContext` and add dynamic provider + registration/unregistration APIs with lifecycle tests. +12. Add the AutoCompletePlugin snippet locator provider and reuse the existing insertion path. +13. Add `include` / `exclude` file-pattern filters using existing glob support. +14. Add the complete pending variable set and casing modifiers from Section 13. +15. Update user documentation and complete Phase 2 acceptance testing. +16. Reassess Configure Snippets and optional Phase 3 only from observed user demand. -Items 1-10 are complete for the current Phase 1 implementation, subject to the hardening notes in -Section 0. Items 11-13 are uncommitted product choices from Phase 2. Item 14 remains optional. +Items 1-15 are complete for the current Phase 1 and Phase 2 implementation, subject to the +hardening notes in Section 0. Exact-prefix Tab completion is explicitly excluded; Configure +Snippets and item 16 remain deferred or optional. -The implementation can stop here if the current autocomplete-driven workflow is sufficient. Phase -2 should be selected feature-by-feature from actual feedback. Phase 3 is not part of the completion -definition for issue #111. +Phase 3 is not part of the completion definition for issue #111. diff --git a/.ecode/c-cpp.code-snippets b/.ecode/c-cpp.code-snippets deleted file mode 100644 index 4bbaeb057..000000000 --- a/.ecode/c-cpp.code-snippets +++ /dev/null @@ -1,57 +0,0 @@ -{ - "Main Function": { - "scope": "c,cpp", - "prefix": ["main", "mainfn"], - "body": [ - "int main(int argc, char** argv) {", - "\t${1:(void)argc;}", - "\t${2:(void)argv;}", - "\t$0", - "\treturn 0;", - "}" - ], - "description": "Program entry point" - }, - "Indexed For Loop": { - "scope": "c,cpp", - "prefix": ["fori", "for-index"], - "body": [ - "for (${1:size_t} ${2:i} = 0; $2 < ${3:count}; ++$2) {", - "\t$0", - "}" - ], - "description": "Indexed loop with linked iterator placeholders" - }, - "Include Header": { - "scope": "c,cpp", - "prefix": "inc", - "body": "#include ${1|,,,\"header.h\"|}$0", - "description": "Include a common system header or a local header" - }, - "Guard Clause": { - "scope": "c,cpp", - "prefix": "guard", - "body": [ - "if (${1:condition}) {", - "\t${2:return;}", - "}", - "$0" - ], - "description": "Early-return guard clause" - }, - "C++ Class": { - "scope": "cpp", - "prefix": "class", - "body": [ - "class ${1:${TM_FILENAME_BASE}} {", - " public:", - "\t$1(${2});", - "\t~$1();", - "", - " private:", - "\t$0", - "};" - ], - "description": "Class named after the current file with linked placeholders" - } -} diff --git a/src/tests/unit_tests/snippetparser_tests.cpp b/src/tests/unit_tests/snippetparser_tests.cpp index db24f0d17..f75e92250 100644 --- a/src/tests/unit_tests/snippetparser_tests.cpp +++ b/src/tests/unit_tests/snippetparser_tests.cpp @@ -113,11 +113,26 @@ UTEST( SnippetParser, variableTransforms ) { auto zeroWidth = SnippetParser::parse( R"(${TM_CURRENT_WORD/(?=.)/_/g})", variables ); EXPECT_STDSTREQ( "_o_n_e_-_t_w_o", zeroWidth.text ); variables["TM_CURRENT_WORD"] = "éx"; - auto unicodeZeroWidth = - SnippetParser::parse( R"(${TM_CURRENT_WORD/(?=.)/_/g})", variables ); + auto unicodeZeroWidth = SnippetParser::parse( R"(${TM_CURRENT_WORD/(?=.)/_/g})", variables ); EXPECT_STDSTREQ( "_é_x", unicodeZeroWidth.text ); } +UTEST( SnippetParser, variableTransformCaseModifiers ) { + SnippetParser::VariableMap variables{ { "TM_CURRENT_WORD", "hello-world value" } }; + EXPECT_STDSTREQ( + "helloWorldValue", + SnippetParser::parse( R"(${TM_CURRENT_WORD/(.*)/${1:/camelcase}/})", variables ).text ); + EXPECT_STDSTREQ( + "HelloWorldValue", + SnippetParser::parse( R"(${TM_CURRENT_WORD/(.*)/${1:/pascalcase}/})", variables ).text ); + EXPECT_STDSTREQ( + "hello_world_value", + SnippetParser::parse( R"(${TM_CURRENT_WORD/(.*)/${1:/snakecase}/})", variables ).text ); + EXPECT_STDSTREQ( + "hello-world-value", + SnippetParser::parse( R"(${TM_CURRENT_WORD/(.*)/${1:/kebabcase}/})", variables ).text ); +} + UTEST( SnippetParser, malformedPlaceholderRemainsLiteral ) { auto parsed = SnippetParser::parse( "before ${1:unfinished" ); EXPECT_STDSTREQ( "before ${1:unfinished", parsed.text ); diff --git a/src/tests/unit_tests/usersnippetstore_tests.cpp b/src/tests/unit_tests/usersnippetstore_tests.cpp index 28fc489f2..99ed9bd4c 100644 --- a/src/tests/unit_tests/usersnippetstore_tests.cpp +++ b/src/tests/unit_tests/usersnippetstore_tests.cpp @@ -122,3 +122,56 @@ UTEST( UserSnippetStore, keepsLastGoodFileAndRemovesSources ) { EXPECT_TRUE( store.removeFile( "user.json" ) ); EXPECT_EQ( 0u, store.size() ); } + +UTEST( UserSnippetStore, filtersByIncludedAndExcludedFilePatterns ) { + UserSnippetStore store; + ASSERT_TRUE( store.updateFile( + R"json({ + "Tests": { + "prefix": "testcase", + "body": "test", + "include": ["*_test.cpp", "tests/**/*.cpp"] + }, + "Sources": { + "prefix": "source", + "body": "source", + "exclude": ["generated/**", "*_test.cpp"] + } + })json", + "cpp.code-snippets", UserSnippetSource::User ) ); + + auto testFile = store.find( "cpp", "", 10, "unit/example_test.cpp" ); + ASSERT_EQ( 1u, testFile.size() ); + EXPECT_STDSTREQ( "Tests", testFile[0].snippet.name ); + + auto nestedTest = + store.findForLocator( "cpp", "test", 10, "tests\\unit\\example.cpp" ); + ASSERT_EQ( 1u, nestedTest.size() ); + EXPECT_STDSTREQ( "Tests", nestedTest[0].snippet.name ); + + auto sourceFile = store.find( "cpp", "", 10, "src/example.cpp" ); + ASSERT_EQ( 1u, sourceFile.size() ); + EXPECT_STDSTREQ( "Sources", sourceFile[0].snippet.name ); + EXPECT_TRUE( store.find( "cpp", "", 10, "generated/example.cpp" ).empty() ); + auto nestedVendorTest = store.find( "cpp", "", 10, "vendor/tests/unit/example.cpp" ); + ASSERT_EQ( 1u, nestedVendorTest.size() ); + EXPECT_STDSTREQ( "Sources", nestedVendorTest[0].snippet.name ); +} + +UTEST( UserSnippetStore, locatorSearchesNamesPrefixesAndDescriptions ) { + UserSnippetStore store; + ASSERT_TRUE( store.updateFile( + R"json({ + "Indexed Loop": { + "prefix": ["fori", "for-index"], + "body": "loop", + "description": "Iterate over values" + } + })json", + "cpp.json", UserSnippetSource::User, "cpp" ) ); + + EXPECT_EQ( 1u, store.findForLocator( "cpp", "Indexed", 10 ).size() ); + EXPECT_EQ( 1u, store.findForLocator( "cpp", "fori", 10 ).size() ); + EXPECT_EQ( 1u, store.findForLocator( "cpp", "values", 10 ).size() ); + EXPECT_TRUE( store.findForLocator( "rust", "Indexed", 10 ).empty() ); +} diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp index 90265027a..3d1f1ce02 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.cpp @@ -1,17 +1,22 @@ #include "autocompleteplugin.hpp" +#include "../../universallocator.hpp" #include #include +#include #include #include #include #include +#include #include #include -#include #include #include #include +#include +#include +#include #include #include using namespace EE::Graphics; @@ -21,6 +26,69 @@ using namespace std::literals; namespace ecode { +class SnippetLocatorModel : public Model { + public: + struct Row { + std::string name; + std::string prefixes; + std::string detail; + std::string body; + }; + + explicit SnippetLocatorModel( std::vector matches ) { + mRows.reserve( matches.size() ); + for ( auto& match : matches ) { + std::string prefixes; + for ( const auto& prefix : match.snippet.prefixes ) { + if ( !prefixes.empty() ) + prefixes += ", "; + prefixes += prefix; + } + const char* source = match.snippet.source == UserSnippetSource::EcodeProject ? ".ecode" + : match.snippet.source == UserSnippetSource::VSCodeProject + ? ".vscode" + : "user"; + std::string detail( std::move( match.snippet.description ) ); + if ( !detail.empty() ) + detail += " — "; + detail += source; + mRows.push_back( { std::move( match.snippet.name ), std::move( prefixes ), + std::move( detail ), std::move( match.snippet.body ) } ); + } + } + + size_t rowCount( const ModelIndex& ) const override { return mRows.size(); } + + size_t columnCount( const ModelIndex& ) const override { return 3; } + + std::string columnName( const size_t& column ) const override { + static constexpr std::array names{ "Name", "Prefixes", "Description" }; + return std::string{ names[column] }; + } + + Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const override { + if ( !index.isValid() || index.row() >= static_cast( mRows.size() ) ) + return {}; + const auto& row = mRows[index.row()]; + if ( role == ModelRole::Custom ) + return Variant{ row.body }; + if ( role != ModelRole::Display ) + return {}; + switch ( index.column() ) { + case 0: + return Variant{ row.name }; + case 1: + return Variant{ row.prefixes }; + case 2: + return Variant{ row.detail }; + } + return {}; + } + + private: + std::vector mRows; +}; + static bool pathStartsWith( std::string_view path, std::string_view prefix ) { return !prefix.empty() && path.size() >= prefix.size() && path.compare( 0, prefix.size(), prefix ) == 0; @@ -199,6 +267,7 @@ AutoCompletePlugin::AutoCompletePlugin( PluginManager* pluginManager, bool sync AutoCompletePlugin::~AutoCompletePlugin() { waitUntilLoaded(); mShuttingDown = true; + unregisterSnippetLocatorProvider(); mManager->unsubscribeMessages( this ); unsubscribeFileSystemListener(); while ( mSnippetJobs > 0 ) @@ -484,6 +553,67 @@ void AutoCompletePlugin::onLoadProject( const std::string& projectFolder, setSnippetWorkspaceFolder( projectFolder ); } +void AutoCompletePlugin::registerSnippetLocatorProvider() { + if ( mSnippetLocatorProviderId != 0 || !getPluginContext() || + !getPluginContext()->getUniversalLocator() ) + return; + auto* locator = getPluginContext()->getUniversalLocator(); + mSnippetLocatorProviderId = locator->registerLocatorProvider( + { "sn", i18n( "insert_snippet", "Insert Snippet" ), nullptr, + [this, locator]( const Variant&, const ModelEvent* event ) { + if ( !event || !event->getModel() || !getPluginContext() ) + return; + auto* editor = getPluginContext()->getSplitter()->getCurEditor(); + if ( !editor ) + return; + const auto body = event->getModel()->data( + event->getModel()->index( event->getModelIndex().row(), 0 ), ModelRole::Custom ); + if ( !body.isValid() ) + return; + mReplacing = true; + insertSnippet( editor, body.toString() ); + mReplacing = false; + resetSuggestions( editor ); + locator->hideLocateBar(); + editor->setFocus(); + }, + nullptr, false, false, + [this]( const String& query, UniversalLocator::LocatorProvider::ModelReadyFn ready ) { + if ( !getPluginContext() || !getPluginContext()->getSplitter() ) { + ready( std::make_shared( std::vector{} ) ); + return; + } + auto* editor = getPluginContext()->getSplitter()->getCurEditor(); + if ( !editor ) { + ready( std::make_shared( std::vector{} ) ); + return; + } + std::string language = editor->getDocument().getSyntaxDefinition().getLSPName(); + std::string filePath = editor->getDocument().getFilePath(); + FileSystem::filePathRemoveBasePath( getPluginContext()->getCurrentProject(), filePath ); + std::string pattern = query.toUtf8(); + ++mSnippetJobs; + mThreadPool->run( [this, language = std::move( language ), + filePath = std::move( filePath ), pattern = std::move( pattern ), + ready = std::move( ready )] { + ScopedOp job( [] {}, [this] { --mSnippetJobs; } ); + if ( mShuttingDown ) + return; + ready( std::make_shared( + mUserSnippetStore.findForLocator( language, pattern, 100, filePath ) ) ); + } ); + } } ); +} + +void AutoCompletePlugin::unregisterSnippetLocatorProvider() { + if ( mSnippetLocatorProviderId == 0 || !getPluginContext() || + !getPluginContext()->getUniversalLocator() ) + return; + getPluginContext()->getUniversalLocator()->unregisterLocatorProvider( + mSnippetLocatorProviderId ); + mSnippetLocatorProviderId = 0; +} + void AutoCompletePlugin::onFileSystemEvent( const FileEvent& ev, const FileInfo& file ) { Plugin::onFileSystemEvent( ev, file ); if ( mShuttingDown || isLoading() ) @@ -536,6 +666,7 @@ void AutoCompletePlugin::onFileSystemEvent( const FileEvent& ev, const FileInfo& } void AutoCompletePlugin::onRegister( UICodeEditor* editor ) { + registerSnippetLocatorProvider(); Lock l( mDocMutex ); std::vector listeners; listeners.push_back( editor->on( Event::OnDocumentLoaded, [this, editor]( const Event* ) { @@ -965,22 +1096,83 @@ void AutoCompletePlugin::updateLangCache( const std::string& langName ) { clock.getElapsedTime().asMilliseconds() ); } -static SnippetParser::VariableMap snippetVariables( TextDocument& doc, - const TextRange& selection ) { +static std::string formatSnippetTime( const std::tm& time, const char* format ) { + char buffer[128]; + return std::strftime( buffer, sizeof( buffer ), format, &time ) > 0 ? buffer : ""; +} + +SnippetParser::VariableMap AutoCompletePlugin::snippetVariables( TextDocument& doc, + const TextRange& selection, + size_t cursorIndex ) const { const TextPosition position = selection.normalized().start(); std::string filePath = doc.getFilePath(); if ( filePath.empty() ) filePath = doc.getLoadingFilePath(); const std::string filename = FileSystem::fileNameFromPath( filePath ); - return { { "TM_SELECTED_TEXT", doc.getText( selection ).toUtf8() }, - { "TM_CURRENT_LINE", doc.getLineTextWithoutNewLine( position.line() ).toUtf8() }, - { "TM_CURRENT_WORD", doc.getWordInPosition( position ).toUtf8() }, - { "TM_LINE_INDEX", String::toString( position.line() ) }, - { "TM_LINE_NUMBER", String::toString( position.line() + 1 ) }, - { "TM_FILENAME", filename }, - { "TM_FILENAME_BASE", FileSystem::fileRemoveExtension( filename ) }, - { "TM_DIRECTORY", FileSystem::fileRemoveFileName( filePath ) }, - { "TM_FILEPATH", filePath } }; + std::string workspaceFolder = getPluginContext() ? getPluginContext()->getCurrentProject() : ""; + std::string relativeFilePath( filePath ); + if ( !workspaceFolder.empty() ) + FileSystem::filePathRemoveBasePath( workspaceFolder, relativeFilePath ); + FileSystem::dirRemoveSlashAtEnd( workspaceFolder ); + std::string workspaceName( FileSystem::fileNameFromPath( workspaceFolder ) ); + + const auto now = std::chrono::system_clock::now(); + const auto nowTime = std::chrono::system_clock::to_time_t( now ); + const std::tm* localTimePtr = std::localtime( &nowTime ); + const std::tm localTime = localTimePtr ? *localTimePtr : std::tm{}; + const auto milliseconds = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() ); + std::string timezoneOffset = formatSnippetTime( localTime, "%z" ); + if ( timezoneOffset.size() == 5 ) + timezoneOffset.insert( 3, ":" ); + + const auto& syntax = doc.getSyntaxDefinition(); + const auto& blockComment = syntax.getBlockComment(); + SnippetParser::VariableMap variables{ + { "TM_SELECTED_TEXT", doc.getText( selection ).toUtf8() }, + { "TM_CURRENT_LINE", doc.getLineTextWithoutNewLine( position.line() ).toUtf8() }, + { "TM_CURRENT_WORD", doc.getWordInPosition( position ).toUtf8() }, + { "TM_LINE_INDEX", String::toString( position.line() ) }, + { "TM_LINE_NUMBER", String::toString( position.line() + 1 ) }, + { "TM_FILENAME", filename }, + { "TM_FILENAME_BASE", FileSystem::fileRemoveExtension( filename ) }, + { "TM_DIRECTORY", FileSystem::fileRemoveFileName( filePath ) }, + { "TM_FILEPATH", filePath }, + { "RELATIVE_FILEPATH", relativeFilePath }, + { "WORKSPACE_NAME", workspaceName }, + { "WORKSPACE_FOLDER", workspaceFolder }, + { "CLIPBOARD", getUISceneNode() && getUISceneNode()->getWindow() + ? getUISceneNode()->getWindow()->getClipboard()->getText() + : "" }, + { "CURSOR_INDEX", String::toString( static_cast( cursorIndex ) ) }, + { "CURSOR_NUMBER", String::toString( static_cast( cursorIndex + 1 ) ) }, + { "CURRENT_YEAR", formatSnippetTime( localTime, "%Y" ) }, + { "CURRENT_YEAR_SHORT", formatSnippetTime( localTime, "%y" ) }, + { "CURRENT_MONTH", formatSnippetTime( localTime, "%m" ) }, + { "CURRENT_MONTH_NAME", formatSnippetTime( localTime, "%B" ) }, + { "CURRENT_MONTH_NAME_SHORT", formatSnippetTime( localTime, "%b" ) }, + { "CURRENT_DATE", formatSnippetTime( localTime, "%d" ) }, + { "CURRENT_DAY_NAME", formatSnippetTime( localTime, "%A" ) }, + { "CURRENT_DAY_NAME_SHORT", formatSnippetTime( localTime, "%a" ) }, + { "CURRENT_HOUR", formatSnippetTime( localTime, "%H" ) }, + { "CURRENT_MINUTE", formatSnippetTime( localTime, "%M" ) }, + { "CURRENT_SECOND", formatSnippetTime( localTime, "%S" ) }, + { "CURRENT_MILLISECOND", + String::format( "%03d", static_cast( milliseconds.count() % 1000 ) ) }, + { "CURRENT_SECONDS_UNIX", + String::toString( static_cast( milliseconds.count() / 1000 ) ) }, + { "CURRENT_MILLISECONDS_UNIX", + String::toString( static_cast( milliseconds.count() ) ) }, + { "CURRENT_TIMEZONE_OFFSET", timezoneOffset }, + { "CURRENT_TIMEZONE_NAME", formatSnippetTime( localTime, "%Z" ) }, + { "RANDOM", String::format( "%06d", Math::randi( 0, 999999 ) ) }, + { "RANDOM_HEX", String::format( "%06x", Math::randi( 0, 0xFFFFFF ) ) }, + { "UUID", UUID().toString() }, + { "LINE_COMMENT", syntax.getComment() }, + { "BLOCK_COMMENT_START", blockComment.open }, + { "BLOCK_COMMENT_END", blockComment.close }, + }; + return variables; } static std::string prepareSnippetText( TextDocument& doc, const TextRange& selection, @@ -1034,6 +1226,47 @@ static TextRange userSnippetActivationRange( TextDocument& doc, const TextRange& return selection; } +void AutoCompletePlugin::insertSnippet( UICodeEditor* editor, std::string_view body, + const Suggestion* suggestion ) { + auto doc = editor->getDocumentRef(); + auto prevSels = doc->getSelections(); + std::vector insertions; + insertions.reserve( prevSels.size() ); + for ( size_t index = 0; index < prevSels.size(); ++index ) { + const auto& selection = prevSels[index]; + const std::string prepared = prepareSnippetText( *doc, selection, body ); + insertions.push_back( + { SnippetParser::parse( prepared, snippetVariables( *doc, selection, index ) ), {} } ); + } + + if ( suggestion && suggestion->source == Suggestion::Source::UserSnippet ) { + const std::string symbol( getPartialSymbol( doc.get() ) ); + for ( size_t index = 0; index < prevSels.size(); ++index ) + doc->setSelection( index, + userSnippetActivationRange( *doc, prevSels[index], + suggestion->matchedPrefix, symbol ) ); + } else if ( suggestion && prevSels.size() == 1 && suggestion->range.isValid() && + doc->isValidRange( suggestion->range ) ) { + doc->setSelection( suggestion->range ); + } else if ( suggestion ) { + const std::string symbol( getPartialSymbol( doc.get() ) ); + if ( !symbol.empty() ) + doc->execute( "delete-to-previous-word" ); + } + if ( insertions.size() > doc->getSelections().size() ) + insertions.resize( doc->getSelections().size() ); + + for ( size_t index = 0; index < insertions.size(); ++index ) { + if ( doc->getSelectionIndex( index ).hasSelection() ) + doc->deleteTo( index, 0 ); + insertions[index].start = doc->getSelectionIndex( index ).start(); + TextPosition end = doc->insert( index, insertions[index].start, + String::fromUtf8( insertions[index].snippet.text ) ); + doc->setSelection( index, end ); + } + tryStartSnippetNav( insertions, editor ); +} + void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) { if ( mSnippetChoiceSuggestions ) return pickSnippetChoice( editor ); @@ -1057,37 +1290,7 @@ void AutoCompletePlugin::pickSuggestion( UICodeEditor* editor ) { doc->textInput( rawInsertText ); } } else { - std::vector insertions; - insertions.reserve( prevSels.size() ); - for ( const auto& selection : prevSels ) { - const std::string prepared = prepareSnippetText( *doc, selection, rawInsertText ); - insertions.push_back( - { SnippetParser::parse( prepared, snippetVariables( *doc, selection ) ), {} } ); - } - - if ( suggestion.source == Suggestion::Source::UserSnippet ) { - for ( size_t index = 0; index < prevSels.size(); ++index ) - doc->setSelection( index, - userSnippetActivationRange( *doc, prevSels[index], - suggestion.matchedPrefix, symbol ) ); - } else if ( prevSels.size() == 1 && suggestion.range.isValid() && - doc->isValidRange( suggestion.range ) ) { - doc->setSelection( suggestion.range ); - } else if ( !symbol.empty() ) { - doc->execute( "delete-to-previous-word" ); - } - if ( insertions.size() > doc->getSelections().size() ) - insertions.resize( doc->getSelections().size() ); - - for ( size_t index = 0; index < insertions.size(); ++index ) { - if ( doc->getSelectionIndex( index ).hasSelection() ) - doc->deleteTo( index, 0 ); - insertions[index].start = doc->getSelectionIndex( index ).start(); - TextPosition end = doc->insert( index, insertions[index].start, - String::fromUtf8( insertions[index].snippet.text ) ); - doc->setSelection( index, end ); - } - tryStartSnippetNav( insertions, editor ); + insertSnippet( editor, rawInsertText, &suggestion ); } mReplacing = false; @@ -2381,7 +2584,11 @@ AutoCompletePlugin::getUserSnippetSuggestions( UICodeEditor* editor, const std:: std::string snippetInput = getUserSnippetInput( editor ); if ( snippetInput.empty() ) snippetInput = symbol; - auto matches = mUserSnippetStore.find( language, snippetInput, maxResults ); + std::string filePath = editor->getDocument().getFilePath(); + if ( getPluginContext() ) + FileSystem::filePathRemoveBasePath( getPluginContext()->getCurrentProject(), filePath ); + auto matches = mUserSnippetStore.find( language, snippetInput, maxResults, + filePath ); suggestions.reserve( matches.size() ); for ( auto& match : matches ) { Suggestion suggestion( LSPCompletionItemKind::Snippet, std::move( match.matchedPrefix ), diff --git a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp index 8b984cb02..79b17bf27 100644 --- a/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp +++ b/src/tools/ecode/plugins/autocomplete/autocompleteplugin.hpp @@ -240,6 +240,7 @@ class AutoCompletePlugin : public Plugin { std::string mSnippetEventPathBuffer; std::atomic mSnippetWorkspaceGeneration{ 0 }; std::atomic mSnippetJobs{ 0 }; + Uint64 mSnippetLocatorProviderId{ 0 }; explicit AutoCompletePlugin( PluginManager* pluginManager, bool sync ); @@ -273,6 +274,16 @@ class AutoCompletePlugin : public Plugin { void scheduleSnippetFileUpdate( std::string path, UserSnippetSource source, bool languageFiles, bool remove ); + void registerSnippetLocatorProvider(); + + void unregisterSnippetLocatorProvider(); + + void insertSnippet( UICodeEditor* editor, std::string_view body, + const Suggestion* suggestion = nullptr ); + + SnippetParser::VariableMap snippetVariables( TextDocument& doc, const TextRange& selection, + size_t cursorIndex ) const; + void updateLangCache( const std::string& langName ); void pickSuggestion( UICodeEditor* editor ); diff --git a/src/tools/ecode/plugins/autocomplete/snippetparser.cpp b/src/tools/ecode/plugins/autocomplete/snippetparser.cpp index 6b31dec54..ae9932177 100644 --- a/src/tools/ecode/plugins/autocomplete/snippetparser.cpp +++ b/src/tools/ecode/plugins/autocomplete/snippetparser.cpp @@ -1,6 +1,7 @@ #include "snippetparser.hpp" #include +#include #include #include #include @@ -203,6 +204,43 @@ class Parser { return result; } + static std::vector caseWords( std::string_view value ) { + std::vector words; + size_t start = 0; + for ( size_t pos = 0; pos <= value.size(); ++pos ) { + const bool atEnd = pos == value.size(); + const Uint8 current = atEnd ? 0 : static_cast( value[pos] ); + const bool separator = !atEnd && current < 0x80 && !std::isalnum( current ); + const bool caseBoundary = !atEnd && pos > start && current < 0x80 && + std::isupper( current ) && + static_cast( value[pos - 1] ) < 0x80 && + std::islower( static_cast( value[pos - 1] ) ); + if ( atEnd || separator || caseBoundary ) { + if ( pos > start ) + words.emplace_back( + String::toLower( std::string{ value.substr( start, pos - start ) } ) ); + start = separator ? pos + 1 : pos; + } + } + return words; + } + + static std::string transformCase( std::string_view value, std::string_view modifier ) { + auto words = caseWords( value ); + std::string output; + for ( size_t index = 0; index < words.size(); ++index ) { + if ( modifier == "/snakecase" && index > 0 ) + output += '_'; + else if ( modifier == "/kebabcase" && index > 0 ) + output += '-'; + if ( modifier == "/pascalcase" || ( modifier == "/camelcase" && index > 0 ) ) + output += String::capitalize( words[index] ); + else + output += words[index]; + } + return output; + } + static std::string transformFormat( std::string_view format, std::string_view value, const std::vector& matches ) { std::string output; @@ -258,6 +296,9 @@ class Parser { output += String::toLower( group ); else if ( body == "/capitalize" ) output += String::capitalize( group ); + else if ( body == "/camelcase" || body == "/pascalcase" || body == "/snakecase" || + body == "/kebabcase" ) + output += transformCase( group, body ); else if ( String::startsWith( body, "+" ) ) output += !group.empty() ? unescapeFormatText( body.substr( 1 ) ) : ""; else if ( String::startsWith( body, "?" ) ) { diff --git a/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp b/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp index e28bb1569..346b22698 100644 --- a/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp +++ b/src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp @@ -61,6 +61,11 @@ static bool parseStringList( const json& value, SmallVector& str return !strings.empty() || !rejectEmpty; } +static void normalizeFilePatterns( SmallVector& patterns ) { + for ( auto& pattern : patterns ) + String::replaceAll( pattern, "\\", "/" ); +} + static bool parseBody( const json& value, std::string& body ) { if ( value.is_string() ) { body = value.get(); @@ -92,6 +97,24 @@ static int sourcePriority( UserSnippetSource source ) { } } +static bool matchesFile( const UserSnippetDefinition& snippet, std::string_view filePath ) { + if ( !snippet.includePatterns.empty() ) { + bool included = false; + for ( const auto& pattern : snippet.includePatterns ) { + if ( String::globMatch( filePath, pattern ) ) { + included = true; + break; + } + } + if ( !included ) + return false; + } + for ( const auto& pattern : snippet.excludePatterns ) + if ( String::globMatch( filePath, pattern ) ) + return false; + return true; +} + } // namespace UserSnippetParseResult UserSnippetStore::parseFile( std::string_view contents, @@ -138,6 +161,20 @@ UserSnippetParseResult UserSnippetStore::parseFile( std::string_view contents, continue; } snippet.scopes = parseScopes( value, defaultScope ); + if ( value.contains( "include" ) && + !parseStringList( value["include"], snippet.includePatterns, true ) ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' has an invalid include pattern" ); + continue; + } + if ( value.contains( "exclude" ) && + !parseStringList( value["exclude"], snippet.excludePatterns, true ) ) { + result.diagnostics.emplace_back( sourcePath + ": snippet '" + name + + "' has an invalid exclude pattern" ); + continue; + } + normalizeFilePatterns( snippet.includePatterns ); + normalizeFilePatterns( snippet.excludePatterns ); result.snippets.emplace_back( std::move( snippet ) ); } return result; @@ -220,8 +257,8 @@ void UserSnippetStore::rebuildSnapshot() { } std::vector UserSnippetStore::find( std::string_view language, - std::string_view pattern, - size_t maxResults ) const { + std::string_view pattern, size_t maxResults, + std::string_view filePath ) const { if ( maxResults == 0 ) return {}; std::shared_ptr snapshot; @@ -230,6 +267,8 @@ std::vector UserSnippetStore::find( std::string_view language, snapshot = mSnapshot; } std::string normalizedLanguage( language ); + std::string normalizedPath( filePath ); + String::replaceAll( normalizedPath, "\\", "/" ); String::toLowerInPlace( normalizedLanguage ); std::vector candidates; candidates.reserve( snapshot->global.size() + 32 ); @@ -263,6 +302,8 @@ std::vector UserSnippetStore::find( std::string_view language, matchedCandidates.reserve( eemin( maxResults, candidates.size() ) ); for ( size_t index : candidates ) { const auto& snippet = snapshot->snippets[index]; + if ( !matchesFile( snippet, normalizedPath ) ) + continue; int bestScore = std::numeric_limits::min(); size_t bestPrefix = NO_INPUT; size_t bestInput = NO_INPUT; @@ -320,6 +361,76 @@ std::vector UserSnippetStore::find( std::string_view language, return matches; } +std::vector UserSnippetStore::findForLocator( std::string_view language, + std::string_view pattern, + size_t maxResults, + std::string_view filePath ) const { + if ( maxResults == 0 ) + return {}; + std::shared_ptr snapshot; + { + Lock lock( mMutex ); + snapshot = mSnapshot; + } + std::string normalizedLanguage( language ); + const std::string query( pattern ); + std::string normalizedPath( filePath ); + String::replaceAll( normalizedPath, "\\", "/" ); + String::toLowerInPlace( normalizedLanguage ); + std::vector candidates; + candidates.reserve( snapshot->global.size() + 32 ); + candidates.insert( candidates.end(), snapshot->global.begin(), snapshot->global.end() ); + auto languageIt = snapshot->byLanguage.find( normalizedLanguage ); + if ( languageIt != snapshot->byLanguage.end() ) + candidates.insert( candidates.end(), languageIt->second.begin(), languageIt->second.end() ); + + struct Candidate { + size_t snippetIndex; + int score; + }; + std::vector matches; + matches.reserve( eemin( maxResults, candidates.size() ) ); + const auto matchScore = [&query]( const std::string& value ) { + const int score = String::fuzzyMatchSimple( query, value, false, true ); + return score > 0 ? score : String::icontains( value, query ) ? 1 : 0; + }; + for ( size_t index : candidates ) { + const auto& snippet = snapshot->snippets[index]; + if ( !matchesFile( snippet, normalizedPath ) ) + continue; + int score = query.empty() ? 0 : matchScore( snippet.name ); + if ( !pattern.empty() ) { + for ( const auto& prefix : snippet.prefixes ) + score = eemax( score, matchScore( prefix ) ); + if ( !snippet.description.empty() ) + score = eemax( score, matchScore( snippet.description ) ); + } + if ( pattern.empty() || score > 0 ) + matches.push_back( { index, score } ); + } + std::sort( matches.begin(), matches.end(), + [&]( const Candidate& left, const Candidate& right ) { + if ( left.score != right.score ) + return left.score > right.score; + const auto& leftSnippet = snapshot->snippets[left.snippetIndex]; + const auto& rightSnippet = snapshot->snippets[right.snippetIndex]; + const int leftPriority = sourcePriority( leftSnippet.source ); + const int rightPriority = sourcePriority( rightSnippet.source ); + if ( leftPriority != rightPriority ) + return leftPriority > rightPriority; + return leftSnippet.name < rightSnippet.name; + } ); + if ( matches.size() > maxResults ) + matches.resize( maxResults ); + std::vector results; + results.reserve( matches.size() ); + for ( const auto& match : matches ) { + const auto& snippet = snapshot->snippets[match.snippetIndex]; + results.push_back( { snippet, snippet.prefixes.front(), {}, match.score } ); + } + return results; +} + size_t UserSnippetStore::size() const { Lock lock( mMutex ); return mSnapshot->snippets.size(); diff --git a/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp b/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp index a5cc5a68f..9b67dfeb1 100644 --- a/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp +++ b/src/tools/ecode/plugins/autocomplete/usersnippetstore.hpp @@ -24,6 +24,8 @@ struct UserSnippetDefinition { std::string body; std::string description; SmallVector scopes; + SmallVector includePatterns; + SmallVector excludePatterns; std::string sourcePath; UserSnippetSource source{ UserSnippetSource::User }; }; @@ -58,7 +60,11 @@ class UserSnippetStore { void clear(); std::vector find( std::string_view language, std::string_view pattern, - size_t maxResults ) const; + size_t maxResults, std::string_view filePath = {} ) const; + + std::vector findForLocator( std::string_view language, + std::string_view pattern, size_t maxResults, + std::string_view filePath = {} ) const; size_t size() const; diff --git a/src/tools/ecode/universallocator.cpp b/src/tools/ecode/universallocator.cpp index 54388ec27..d4ba06095 100644 --- a/src/tools/ecode/universallocator.cpp +++ b/src/tools/ecode/universallocator.cpp @@ -431,6 +431,7 @@ UniversalLocator::UniversalLocator( UICodeEditorSplitter* editorSplitter, UIScen } void UniversalLocator::hideLocateBar() { + ++mLocatorModelGeneration; mLocateBarLayout->setVisible( false ); mLocateTable->setVisible( false ); mApp->getStatusBar()->updateState(); @@ -535,6 +536,7 @@ void UniversalLocator::goToLine() { } bool UniversalLocator::isCommand( const std::string& filename ) { + Lock lock( mLocatorProvidersMutex ); const auto isLocator = [this]( const std::string& filename ) { return std::find_if( mLocatorProviders.begin(), mLocatorProviders.end(), [&filename]( const LocatorProvider& provider ) { @@ -546,6 +548,7 @@ bool UniversalLocator::isCommand( const std::string& filename ) { } std::optional UniversalLocator::getLocator( const String& txt ) { + Lock lock( mLocatorProvidersMutex ); for ( const auto& locator : mLocatorProviders ) if ( locator.matches( txt ) ) return locator; @@ -554,6 +557,7 @@ std::optional UniversalLocator::getLocator( c bool UniversalLocator::isLocator( const String& txt ) { if ( !txt.empty() ) { + Lock lock( mLocatorProvidersMutex ); for ( const auto& locator : mLocatorProviders ) if ( locator.matches( txt ) ) return true; @@ -564,8 +568,26 @@ bool UniversalLocator::isLocator( const String& txt ) { bool UniversalLocator::tryLocator( const String& txt ) { if ( txt.empty() ) return false; - for ( const auto& locator : mLocatorProviders ) { - if ( locator.matches( txt ) && locator.switchFn( txt ) ) + auto locator = getLocator( txt ); + if ( locator ) { + if ( locator->modelFn ) { + String query( txt.substr( locator->triggerSize( txt ) ) ); + query.trim(); + const Uint64 generation = ++mLocatorModelGeneration; + locator->modelFn( query, [this, generation]( std::shared_ptr model ) { + mUISceneNode->runOnMainThread( [this, generation, model = std::move( model )] { + if ( generation != mLocatorModelGeneration || !mLocateBarLayout->isVisible() ) + return; + mLocateTable->setModel( model ); + if ( model && model->hasChildren() ) + mLocateTable->getSelection().set( model->index( 0 ) ); + mLocateTable->scrollToTop(); + updateLocateBarSync(); + } ); + } ); + return true; + } + if ( locator->switchFn && locator->switchFn( txt ) ) return true; } return false; @@ -573,23 +595,49 @@ bool UniversalLocator::tryLocator( const String& txt ) { bool UniversalLocator::openLocator( const String& txt, const Variant& vName, const ModelEvent* modelEvent ) { - for ( const auto& locator : mLocatorProviders ) { - if ( locator.matches( txt ) && locator.openFn ) { - locator.openFn( vName, modelEvent ); - return true; - } + auto locator = getLocator( txt ); + if ( locator && locator->openFn ) { + locator->openFn( vName, modelEvent ); + return true; } return false; } bool UniversalLocator::pressEnterLocator( const String& txt ) { - for ( const auto& locator : mLocatorProviders ) { - if ( locator.matches( txt ) && locator.pressEnterFn && locator.pressEnterFn( txt ) ) - return true; - } + auto locator = getLocator( txt ); + if ( locator && locator->pressEnterFn && locator->pressEnterFn( txt ) ) + return true; return false; } +Uint64 UniversalLocator::registerLocatorProvider( LocatorProvider provider ) { + if ( provider.symbol.empty() || ( !provider.switchFn && !provider.modelFn ) ) + return 0; + Lock lock( mLocatorProvidersMutex ); + if ( std::find_if( mLocatorProviders.begin(), mLocatorProviders.end(), + [&provider]( const LocatorProvider& current ) { + return current.symbol == provider.symbol; + } ) != mLocatorProviders.end() ) + return 0; + provider.id = ++mLastLocatorProviderId; + mLocatorProviders.emplace_back( std::move( provider ) ); + return mLastLocatorProviderId; +} + +bool UniversalLocator::unregisterLocatorProvider( Uint64 providerId ) { + if ( providerId == 0 ) + return false; + Lock lock( mLocatorProvidersMutex ); + auto provider = std::find_if( + mLocatorProviders.begin(), mLocatorProviders.end(), + [providerId]( const LocatorProvider& current ) { return current.id == providerId; } ); + if ( provider == mLocatorProviders.end() ) + return false; + mLocatorProviders.erase( provider ); + ++mLocatorModelGeneration; + return true; +} + void UniversalLocator::initLocateBar( UILocateBar* locateBar, UITextInput* locateInput ) { mLocateBarLayout = locateBar; mLocateInput = locateInput; @@ -1287,6 +1335,7 @@ std::vector UniversalLocator::getLocatorComma std::vector vec; UIIcon* icon = mUISceneNode->findIcon( "chevron-right" ); bool isOpenFolder = !mApp->getCurrentProject().empty(); + Lock lock( mLocatorProvidersMutex ); for ( const auto& locator : mLocatorProviders ) { if ( !isOpenFolder && locator.projectNeeded ) continue; diff --git a/src/tools/ecode/universallocator.hpp b/src/tools/ecode/universallocator.hpp index f6eab5eda..95a0b9802 100644 --- a/src/tools/ecode/universallocator.hpp +++ b/src/tools/ecode/universallocator.hpp @@ -18,11 +18,14 @@ class OpenDocumentsModel; class UniversalLocator { public: struct LocatorProvider { + using ModelReadyFn = std::function )>; + using ModelFn = std::function; + LocatorProvider( String&& symbol, String&& description, std::function switchFn, std::function openFn, std::function pressEnterFn = nullptr, bool projectNeeded = true, - bool spaceOptional = false ) : + bool spaceOptional = false, ModelFn modelFn = nullptr ) : symbol( std::move( symbol ) ), symbolTrigger( this->symbol + " " ), description( std::move( description ) ), @@ -30,7 +33,8 @@ class UniversalLocator { openFn( std::move( openFn ) ), pressEnterFn( std::move( pressEnterFn ) ), projectNeeded( projectNeeded ), - spaceOptional( spaceOptional ) {} + spaceOptional( spaceOptional ), + modelFn( std::move( modelFn ) ) {} bool matches( const String& txt ) const { return String::startsWith( txt, symbolTrigger ) || @@ -49,6 +53,8 @@ class UniversalLocator { std::function pressEnterFn{ nullptr }; bool projectNeeded{ true }; bool spaceOptional{ false }; + ModelFn modelFn; + Uint64 id{ 0 }; }; UniversalLocator( UICodeEditorSplitter* editorSplitter, UISceneNode* sceneNode, App* app ); @@ -91,6 +97,13 @@ class UniversalLocator { void showCalculator(); + /** Registers a locator provider and returns its opaque ownership handle, or zero when the + * provider is invalid or its symbol is already registered. */ + Uint64 registerLocatorProvider( LocatorProvider provider ); + + /** Unregisters the locator provider associated with @p providerId. */ + bool unregisterLocatorProvider( Uint64 providerId ); + protected: UILocateBar* mLocateBarLayout{ nullptr }; UITableView* mLocateTable{ nullptr }; @@ -107,6 +120,9 @@ class UniversalLocator { std::shared_ptr mOpenDocumentsModel{ nullptr }; PluginIDType mQueryWorkspaceLastId; std::vector mLocatorProviders; + mutable Mutex mLocatorProvidersMutex; + Uint64 mLastLocatorProviderId{ 0 }; + Uint64 mLocatorModelGeneration{ 0 }; std::unordered_map mCalculatorVariables; std::vector> mCalculatorHistory;