diff --git a/CMakeLists.txt b/CMakeLists.txt index 49bb34d21..92b2e3977 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,9 @@ if (NOT USE_SYSTEM_KF6 OR APPLE) import_kf6() endif() +include(Fff) +fff_configure() + if (APPLE) find_program(BREW_EXECUTABLE brew) if (BREW_EXECUTABLE) diff --git a/cmake/Fff.cmake b/cmake/Fff.cmake new file mode 100644 index 000000000..a25bb1e53 --- /dev/null +++ b/cmake/Fff.cmake @@ -0,0 +1,230 @@ +# opts: +# FFF_VERSION (string) - release tag / git ref. +# FFF_LIBC (glibc|musl|auto) - only for prebuilt Linux. +# FFF_BUILD_FROM_SOURCE (BOOL, default OFF) - cargo build fff locally. +# FFF_CARGO_FEATURES (string, default "") - comma list for --features. +# FFF_CARGO_PROFILE (release|dev, default release) +# +# outputs of ${CMAKE_BINARY_DIR}/_fff: +# _fff/lib/libfff_c. (prebuilt mode) +# _fff/include/fff.h (prebuilt mode) +# _fff/.stamp-- (prebuilt cache invalidator) +# _fff/src/ (source-build clone) +# _fff/cargo//libfff_c. (source-build output) + +set(FFF_VERSION "v0.8.1" CACHE STRING "fff release tag / git ref to use") +set(FFF_LIBC "auto" CACHE STRING "Linux C library variant for fff: glibc | musl | auto") +set_property(CACHE FFF_LIBC PROPERTY STRINGS "auto" "glibc" "musl") +option(FFF_BUILD_FROM_SOURCE "Build libfff_c locally with cargo (requires Rust toolchain)" OFF) +set(FFF_CARGO_FEATURES "" CACHE STRING "fff feature flags, provide 'zlob' if you have zig toolchain installed") +set(FFF_CARGO_PROFILE "release" CACHE STRING "Cargo profile for fff-c (release | dev)") +set_property(CACHE FFF_CARGO_PROFILE PROPERTY STRINGS "release" "dev") + +function(_fff_detect_libc out_libc) + # probe ldd --version if output + execute_process( + COMMAND ldd --version + OUTPUT_VARIABLE _ldd_out + ERROR_VARIABLE _ldd_err + TIMEOUT 5) + + if ("${_ldd_out}${_ldd_err}" MATCHES "musl") + set(${out_libc} "musl" PARENT_SCOPE) + else() + set(${out_libc} "glibc" PARENT_SCOPE) + endif() +endfunction() + +function(_fff_detect_triple out_triple out_ext) + # Normalize processor + set(_proc "${CMAKE_SYSTEM_PROCESSOR}") + if (_proc STREQUAL "AMD64" OR _proc STREQUAL "x64") + set(_proc "x86_64") + elseif (_proc STREQUAL "arm64") + set(_proc "aarch64") + endif() + + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(_libc "${FFF_LIBC}") + if (_libc STREQUAL "auto") + _fff_detect_libc(_libc) + message(STATUS "fff: auto-detected libc=${_libc}") + endif() + + if (_libc STREQUAL "musl") + set(${out_triple} "${_proc}-unknown-linux-musl" PARENT_SCOPE) + else() + set(${out_triple} "${_proc}-unknown-linux-gnu" PARENT_SCOPE) + endif() + set(${out_ext} "so" PARENT_SCOPE) + elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin") # is vicinae even compiled for macos? would be fun lol + set(${out_triple} "${_proc}-apple-darwin" PARENT_SCOPE) + set(${out_ext} "dylib" PARENT_SCOPE) + else() + message(FATAL_ERROR "fff does not publish a prebuilt C library for ${CMAKE_SYSTEM_NAME} ${CMAKE_SYSTEM_PROCESSOR}. Build from source with -DFFF_BUILD_FROM_SOURCE=ON.") + endif() +endfunction() + +function(_fff_download url dst) + message(STATUS "fff: fetching ${url}") + file(DOWNLOAD "${url}" "${dst}" + TLS_VERIFY ON + STATUS _status) + list(GET _status 0 _rc) + if (NOT _rc EQUAL 0) + list(GET _status 1 _err) + message(FATAL_ERROR "fff: failed to download ${url}: ${_err}") + endif() +endfunction() + +function(_fff_configure_prebuilt) + _fff_detect_triple(_triple _ext) + + set(_root "${CMAKE_BINARY_DIR}/_fff") + set(_lib_dir "${_root}/lib") + set(_inc_dir "${_root}/include") + set(_libfile "${_lib_dir}/libfff_c.${_ext}") + set(_hdrfile "${_inc_dir}/fff.h") + set(_stamp "${_root}/.stamp-${FFF_VERSION}-${_triple}") + + if (NOT EXISTS "${_stamp}") + # Version or triple changed. Wipe any prior prebuilt cache. + if (EXISTS "${_lib_dir}") + file(REMOVE_RECURSE "${_lib_dir}") + endif() + if (EXISTS "${_inc_dir}") + file(REMOVE_RECURSE "${_inc_dir}") + endif() + file(GLOB _old_stamps "${_root}/.stamp-*") + if (_old_stamps) + file(REMOVE ${_old_stamps}) + endif() + file(MAKE_DIRECTORY "${_lib_dir}" "${_inc_dir}") + + set(_base "https://github.com/dmtrKovalenko/fff/releases/download/${FFF_VERSION}") + set(_asset "c-lib-${_triple}.${_ext}") + _fff_download("${_base}/${_asset}" "${_libfile}") + _fff_download( + "https://raw.githubusercontent.com/dmtrKovalenko/fff/${FFF_VERSION}/crates/fff-c/include/fff.h" + "${_hdrfile}") + + file(WRITE "${_stamp}" "${FFF_VERSION} ${_triple}\n") + endif() + + add_library(vicinae::fff SHARED IMPORTED GLOBAL) + set_target_properties(vicinae::fff PROPERTIES + IMPORTED_LOCATION "${_libfile}" + IMPORTED_NO_SONAME TRUE + INTERFACE_INCLUDE_DIRECTORIES "${_inc_dir}") + + set(FFF_RUNTIME_LIBRARY "${_libfile}" CACHE INTERNAL "" FORCE) + message(STATUS "fff: prebuilt ${FFF_VERSION} (${_triple}) at ${_libfile}") +endfunction() + +function(_fff_configure_source) + find_program(CARGO cargo) + if (NOT CARGO) + message(FATAL_ERROR + "fff: cargo not found but -DFFF_BUILD_FROM_SOURCE=ON was requested.\n" + "Install the Rust toolchain (https://rustup.rs) or unset " + "-DFFF_BUILD_FROM_SOURCE to use the prebuilt binary.") + endif() + + # Library extension still comes from the triple detector; we only use the + # extension part for source builds. + _fff_detect_triple(_ignore_triple _ext) + + include(FetchContent) + FetchContent_Declare( + fff_src + GIT_REPOSITORY https://github.com/dmtrKovalenko/fff.git + GIT_TAG ${FFF_VERSION} + GIT_SHALLOW TRUE + EXCLUDE_FROM_ALL + SOURCE_DIR "${CMAKE_BINARY_DIR}/_fff/src" + ) + + # do not invoke subdir becuase fff is a rust project without cmake + FetchContent_GetProperties(fff_src) + if (NOT fff_src_POPULATED) + message(STATUS "fff: cloning source tree ${FFF_VERSION}") + # FetchContent_Populate is deprecated in 3.30+ but still the supported + # way to populate without add_subdirectory. Quiet the warning. + if (POLICY CMP0169) + cmake_policy(PUSH) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(fff_src) + if (POLICY CMP0169) + cmake_policy(POP) + endif() + endif() + + set(_src_dir "${fff_src_SOURCE_DIR}") + set(_cargo_dir "${CMAKE_BINARY_DIR}/_fff/cargo") + set(_profile_dir_name "${FFF_CARGO_PROFILE}") + if (FFF_CARGO_PROFILE STREQUAL "dev") + # cargo's `dev` profile outputs into `debug/`. + set(_profile_dir_name "debug") + endif() + set(_libfile "${_cargo_dir}/${_profile_dir_name}/libfff_c.${_ext}") + set(_hdrdir "${_src_dir}/crates/fff-c/include") + + set(_cargo_args build -p fff-c + --manifest-path "${_src_dir}/Cargo.toml" + --target-dir "${_cargo_dir}") + + if (FFF_CARGO_PROFILE STREQUAL "release") + list(APPEND _cargo_args --release) + elseif (NOT FFF_CARGO_PROFILE STREQUAL "dev") + message(FATAL_ERROR "fff: FFF_CARGO_PROFILE must be 'release' or 'dev' (got '${FFF_CARGO_PROFILE}')") + endif() + + if (FFF_CARGO_FEATURES) + string(REPLACE " " "," _features "${FFF_CARGO_FEATURES}") + list(APPEND _cargo_args --features "${_features}") + endif() + + # cargo manages it's own compilation, so we invalicate it on version or rust code change + file(GLOB_RECURSE _fff_src_glob + CONFIGURE_DEPENDS + "${_src_dir}/crates/fff-c/src/*.rs" + "${_src_dir}/crates/fff-c/build.rs" + "${_src_dir}/crates/fff-c/Cargo.toml") + + add_custom_command( + OUTPUT "${_libfile}" + COMMAND ${CARGO} ${_cargo_args} + WORKING_DIRECTORY "${_src_dir}" + DEPENDS + "${_src_dir}/Cargo.toml" + "${_src_dir}/Cargo.lock" + ${_fff_src_glob} + COMMENT "fff: cargo build (${FFF_CARGO_PROFILE}, features=${FFF_CARGO_FEATURES})" + VERBATIM + USES_TERMINAL) + + add_custom_target(fff_c_build ALL DEPENDS "${_libfile}") + + add_library(vicinae::fff SHARED IMPORTED GLOBAL) + set_target_properties(vicinae::fff PROPERTIES + IMPORTED_LOCATION "${_libfile}" + IMPORTED_NO_SONAME TRUE + INTERFACE_INCLUDE_DIRECTORIES "${_hdrdir}") + add_dependencies(vicinae::fff fff_c_build) + + set(FFF_RUNTIME_LIBRARY "${_libfile}" CACHE INTERNAL "" FORCE) + message(STATUS "fff: building from source (${FFF_VERSION}, profile=${FFF_CARGO_PROFILE}, features=${FFF_CARGO_FEATURES}) -> ${_libfile}") +endfunction() + +function(fff_configure) + if (TARGET vicinae::fff) + return() + endif() + + if (FFF_BUILD_FROM_SOURCE) + _fff_configure_source() + else() + _fff_configure_prebuilt() + endif() +endfunction() diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index c5900bb71..3f7e0121d 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -15,7 +15,7 @@ list(APPEND LIBS Qt6::Sql Qt6::Network Qt6::Svg Qt6::DBus Qt6::Concurrent Qt6::Quick Qt6::Qml Qt6::GuiPrivate # for deeper integration with wayland protocols, we need wl_surface Qt6::QuickDialogs2 Qt6::QuickControls2 - ${CMARK_LIBRARY} ${CMARK_EXT_LIBRARY} + ${CMARK_EXT_LIBRARY} ${CMARK_LIBRARY} minizip OpenSSL::Crypto qt6keychain @@ -27,6 +27,8 @@ list(APPEND LIBS vicinae::emoji vicinae::common vicinae::fuzzy + vicinae::linuxutils + vicinae::fff ) if (UNIX AND NOT APPLE) @@ -356,6 +358,11 @@ set(SRCS src/services/files-service/file-service.hpp src/services/files-service/file-service.cpp + src/services/files-service/fff/fff-library.hpp + src/services/files-service/fff/fff-library.cpp + src/services/files-service/fff/fff-file-indexer.hpp + src/services/files-service/fff/fff-file-indexer.cpp + src/services/extension-registry/extension-registry.hpp src/services/extension-registry/extension-registry.cpp src/services/extension-registry/extension-manifest.hpp @@ -937,6 +944,30 @@ if(NOT APPLE) ) endif() +# Ship libfff_c.so next to vicinae-server and make the linker find it at +# runtime through a relative rpath. $ORIGIN resolves to the directory of the +# server binary at execution time. +if (FFF_RUNTIME_LIBRARY) + install(FILES "${FFF_RUNTIME_LIBRARY}" + DESTINATION ${VICINAE_LIBEXEC_DIR} + PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + + if (APPLE) + set(_fff_rpath "@loader_path") + else() + set(_fff_rpath "$ORIGIN") + endif() + + set_target_properties(${TARGET} PROPERTIES + BUILD_WITH_INSTALL_RPATH FALSE + BUILD_RPATH "${CMAKE_BINARY_DIR}/_fff/lib" + INSTALL_RPATH "${_fff_rpath}" + INSTALL_RPATH_USE_LINK_PATH TRUE) +endif() + if (BUILD_TESTS AND UNIX AND NOT APPLE) set(TEST_TARGET ${TARGET}-tests) find_package(Catch2 3 REQUIRED) @@ -949,3 +980,41 @@ if (BUILD_TESTS AND UNIX AND NOT APPLE) target_link_libraries(${TEST_TARGET} PRIVATE Catch2::Catch2WithMain Qt6::Core Qt6::Gui) target_compile_features(${TEST_TARGET} PUBLIC cxx_std_23) endif() + +# Standalone smoke test for the fff integration. Not gated on BUILD_TESTS, +# because it does not require Catch2 - builds opt-in via -DBUILD_FFF_SMOKE=ON. +option(BUILD_FFF_SMOKE "Build the fff integration smoke test binary" OFF) +if (BUILD_FFF_SMOKE) + set(FFF_SMOKE_TARGET ${TARGET}-fff-smoke) + add_executable(${FFF_SMOKE_TARGET} + tests/fff-smoke.cpp + src/services/files-service/abstract-file-indexer.hpp + src/services/files-service/fff/fff-library.cpp + src/services/files-service/fff/fff-file-indexer.hpp + src/services/files-service/fff/fff-file-indexer.cpp + src/utils/utils.cpp + src/vicinae.cpp + ) + set_target_properties(${FFF_SMOKE_TARGET} PROPERTIES AUTOMOC ON) + target_include_directories(${FFF_SMOKE_TARGET} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/lib + ${CMAKE_CURRENT_SOURCE_DIR}/src/utils + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_BINARY_DIR}) + target_link_libraries(${FFF_SMOKE_TARGET} PRIVATE + Qt6::Core Qt6::Concurrent Qt6::Gui + glaze::glaze + vicinae::fff + vicinae::xdgpp) + target_compile_features(${FFF_SMOKE_TARGET} PUBLIC cxx_std_26) + + if (APPLE) + set(_fff_smoke_rpath "@loader_path") + else() + set(_fff_smoke_rpath "$ORIGIN") + endif() + set_target_properties(${FFF_SMOKE_TARGET} PROPERTIES + BUILD_RPATH "${CMAKE_BINARY_DIR}/_fff/lib" + INSTALL_RPATH "${_fff_smoke_rpath}") +endif() diff --git a/src/server/src/extensions/file/file-extension.hpp b/src/server/src/extensions/file/file-extension.hpp index fbd4f41a4..7d983bf05 100644 --- a/src/server/src/extensions/file/file-extension.hpp +++ b/src/server/src/extensions/file/file-extension.hpp @@ -62,8 +62,11 @@ class FileExtension : public BuiltinCommandRepository { public: void initialized(const QJsonObject &preferences) const override { - auto files = ServiceRegistry::instance()->fileService(); - if (preferences.value("autoIndexing").toBool()) { files->indexer()->start(); } + // TODO check this out on review: I do not think that it makes sense to do any file system + // scanning in the background, on ext4 wired with getdents64 + the default garbage filters + // fff has (e.g. skipping node modules, targets, and binary files) 1TB files would be indexed + // within a few seconds, so I do not think there is a reason to keep all of those in memory + Q_UNUSED(preferences); } FileExtension() { @@ -97,7 +100,15 @@ class FileExtension : public BuiltinCommandRepository { watcherPaths.setDescription("Semicolon-separated list of paths watched by experimental watcher"); watcherPaths.setDefaultValue(""); - return {indexing, paths, excludedPaths, watcherPaths}; + auto reuseNvim = Preference::makeCheckbox("reuseNvimDbs"); + reuseNvim.setTitle("Reuse fff.nvim databases"); + reuseNvim.setDescription( + "When enabled and the fff.nvim plugin's frecency / history databases are detected at " + "$XDG_CACHE_HOME/nvim/fff_nvim and $XDG_DATA_HOME/nvim/fff_queries, vicinae will share them so " + "file-ranking learning carries over both ways. Disable if you hit fff schema/version errors."); + reuseNvim.setDefaultValue(true); + + return {indexing, paths, excludedPaths, watcherPaths, reuseNvim}; } void preferenceValuesChanged(const QJsonObject &preferences) const override { diff --git a/src/server/src/qml/launcher-window.cpp b/src/server/src/qml/launcher-window.cpp index d117503a3..176e708b2 100644 --- a/src/server/src/qml/launcher-window.cpp +++ b/src/server/src/qml/launcher-window.cpp @@ -21,6 +21,8 @@ #include "config/config.hpp" #include "service-registry.hpp" #include "services/file-chooser/file-chooser-service.hpp" +#include "services/files-service/abstract-file-indexer.hpp" +#include "services/files-service/file-service.hpp" #include "services/window-manager/window-manager.hpp" #include "environment.hpp" #include "vicinae.hpp" @@ -348,6 +350,12 @@ void LauncherWindow::handleVisibilityChanged(bool visible) { } else { m_window->hide(); m_cacheEvictionTimer.start(); + // fff scanning on linux is failry fast and parallel, there is no reason + // to keep it in memory on the backend side especailly given the fact that the files + // are going to be changed between reruns of the file service + if (auto *files = m_ctx.services->fileService()) { + if (auto *indexer = files->indexer()) { indexer->stop(); } + } } } diff --git a/src/server/src/qml/qml/SearchFilesView.qml b/src/server/src/qml/qml/SearchFilesView.qml index ee9dc96ab..1deb0526f 100644 --- a/src/server/src/qml/qml/SearchFilesView.qml +++ b/src/server/src/qml/qml/SearchFilesView.qml @@ -26,6 +26,8 @@ Item { cmdModel: root.host.listModel detailComponent: detailPanel detailVisible: root.host.hasDetail + emptyTitle: root.host.emptyTitle + emptyDescription: root.host.emptyDescription } Component { diff --git a/src/server/src/qml/search-files-model.cpp b/src/server/src/qml/search-files-model.cpp index a4f35c91d..cdf24a38a 100644 --- a/src/server/src/qml/search-files-model.cpp +++ b/src/server/src/qml/search-files-model.cpp @@ -4,16 +4,27 @@ #include "service-registry.hpp" #include "utils/utils.hpp" +namespace fs = std::filesystem; + void SearchFilesSection::setFiles(std::vector files, const QString §ionName) { m_files = std::move(files); m_sectionName = sectionName; notifyChanged(); } +QString SearchFilesSection::itemId(int i) const { return QString::fromStdString(m_files.at(i).string()); } + QString SearchFilesSection::itemTitle(int i) const { return QString::fromStdString(getLastPathComponent(m_files.at(i))); } +QString SearchFilesSection::itemSubtitle(int i) const { + const auto &p = m_files.at(i); + fs::path parent = p.parent_path(); + if (parent.empty()) return QString::fromStdString(compressPath(p).string()); + return QString::fromStdString(compressPath(parent).string()); +} + QString SearchFilesSection::itemIconSource(int i) const { return imageSourceFor(ImageURL::fileIcon(m_files.at(i))); } diff --git a/src/server/src/qml/search-files-model.hpp b/src/server/src/qml/search-files-model.hpp index 01fb4dfac..286807adf 100644 --- a/src/server/src/qml/search-files-model.hpp +++ b/src/server/src/qml/search-files-model.hpp @@ -21,7 +21,9 @@ class SearchFilesSection : public SectionSource { } protected: + QString itemId(int i) const override; QString itemTitle(int i) const override; + QString itemSubtitle(int i) const override; QString itemIconSource(int i) const override; std::unique_ptr actionPanel(int i) const override; diff --git a/src/server/src/qml/search-files-view-host.cpp b/src/server/src/qml/search-files-view-host.cpp index 2707065ab..7f13c55a1 100644 --- a/src/server/src/qml/search-files-view-host.cpp +++ b/src/server/src/qml/search-files-view-host.cpp @@ -4,6 +4,7 @@ #include "utils/utils.hpp" #include "view-utils.hpp" #include +#include #include namespace fs = std::filesystem; @@ -23,23 +24,56 @@ void SearchFilesViewHost::initialize() { m_section.setOnFileSelected([this](const fs::path &p) { loadDetail(p); }); model()->addSource(&m_section); - setSearchPlaceholderText("Search for files..."); m_debounce.setSingleShot(true); - m_debounce.setInterval(100ms); + m_debounce.setInterval(32ms); // fff is fast enough to run even in 60 fps connect(&m_debounce, &QTimer::timeout, this, &SearchFilesViewHost::handleDebounce); connect(&m_pendingResults, &Watcher::finished, this, &SearchFilesViewHost::handleSearchResults); + + m_readyPulseTimer.setSingleShot(true); + m_readyPulseTimer.setInterval(2000); + connect(&m_readyPulseTimer, &QTimer::timeout, this, [this]() { + if (!m_showReadyPulse) return; + m_showReadyPulse = false; + emit indexingStateChanged(); + if (searchText().isEmpty()) renderRecentFiles(); + }); + + if (auto *fileService = context()->services->fileService()) { + if (auto *indexer = fileService->indexer()) { + connect(indexer, &AbstractFileIndexer::scanStateChanged, this, &SearchFilesViewHost::handleScanState); + // Seed local state in case the view is re-entered after the index is + // already ready. + auto state = indexer->scanState(); + handleScanState(static_cast(state.scannedFilesCount), state.isScanning, state.isReady); + } + } } -void SearchFilesViewHost::loadInitialData() { renderRecentFiles(); } +void SearchFilesViewHost::loadInitialData() { + // Entering the view is the trigger for indexing. start() is idempotent. + if (auto *fileService = context()->services->fileService()) { + if (auto *indexer = fileService->indexer()) indexer->start(); + } + + if (m_isIndexing || m_showReadyPulse) { + clearSection(); + return; + } + renderRecentFiles(); +} void SearchFilesViewHost::textChanged(const QString &text) { if (m_pendingResults.isRunning()) m_pendingResults.cancel(); if (text.isEmpty()) { m_debounce.stop(); - renderRecentFiles(); + if (m_isIndexing || m_showReadyPulse) { + clearSection(); + } else { + renderRecentFiles(); + } return; } @@ -64,6 +98,11 @@ void SearchFilesViewHost::renderRecentFiles() { m_section.setFiles(std::move(recentFiles), QStringLiteral("Recently Accessed")); } +void SearchFilesViewHost::clearSection() { + setLoading(false); + m_section.setFiles({}, QString()); +} + void SearchFilesViewHost::handleDebounce() { auto fileService = context()->services->fileService(); QString const query = searchText(); @@ -117,3 +156,61 @@ void SearchFilesViewHost::clearDetail() { m_detailTextContent.clear(); emit detailChanged(); } + +void SearchFilesViewHost::handleScanState(quint64 scanned, bool scanning, bool ready) { + bool changed = false; + if (m_indexedFilesCount != scanned) { + m_indexedFilesCount = scanned; + changed = true; + } + + bool const indexing = scanning && !ready; + if (m_isIndexing != indexing) { + m_isIndexing = indexing; + changed = true; + } + + bool const wasReady = m_indexReady; + if (m_indexReady != ready) { + m_indexReady = ready; + changed = true; + } + + if (!wasReady && ready) { + m_readyAnnounceCount = scanned; + m_showReadyPulse = true; + clearSection(); + m_readyPulseTimer.start(); + changed = true; + } + + if (wasReady && !ready) { + if (m_showReadyPulse) { + m_showReadyPulse = false; + m_readyPulseTimer.stop(); + changed = true; + } + if (searchText().isEmpty()) renderRecentFiles(); + } + + if (changed) emit indexingStateChanged(); +} + +QString SearchFilesViewHost::emptyTitle() const { + if (m_isIndexing) return QStringLiteral("Indexing your $HOME"); + if (m_showReadyPulse) return QStringLiteral("Ready to search"); + return QStringLiteral("No results"); +} + +QString SearchFilesViewHost::emptyDescription() const { + if (m_isIndexing) { + if (m_indexedFilesCount == 0) return QStringLiteral("Starting up — this only happens once."); + auto count = QLocale::system().toString(m_indexedFilesCount); + return QStringLiteral("%1 files scanned so far").arg(count); + } + if (m_showReadyPulse) { + auto count = QLocale::system().toString(m_readyAnnounceCount); + return QStringLiteral("Indexed %1 files. Type to search.").arg(count); + } + return QString(); +} diff --git a/src/server/src/qml/search-files-view-host.hpp b/src/server/src/qml/search-files-view-host.hpp index dd9c47ad6..34fb13942 100644 --- a/src/server/src/qml/search-files-view-host.hpp +++ b/src/server/src/qml/search-files-view-host.hpp @@ -16,8 +16,15 @@ class SearchFilesViewHost : public ListViewHost { Q_PROPERTY(QString detailImageSource READ detailImageSource NOTIFY detailChanged) Q_PROPERTY(QString detailTextContent READ detailTextContent NOTIFY detailChanged) + Q_PROPERTY(bool isIndexing READ isIndexing NOTIFY indexingStateChanged) + Q_PROPERTY(bool showReadyPulse READ showReadyPulse NOTIFY indexingStateChanged) + Q_PROPERTY(quint64 indexedFilesCount READ indexedFilesCount NOTIFY indexingStateChanged) + Q_PROPERTY(QString emptyTitle READ emptyTitle NOTIFY indexingStateChanged) + Q_PROPERTY(QString emptyDescription READ emptyDescription NOTIFY indexingStateChanged) + signals: void detailChanged(); + void indexingStateChanged(); public: QUrl qmlComponentUrl() const override; @@ -34,10 +41,18 @@ class SearchFilesViewHost : public ListViewHost { QString detailImageSource() const { return m_detailImageSource; } QString detailTextContent() const { return m_detailTextContent; } + bool isIndexing() const { return m_isIndexing; } + bool showReadyPulse() const { return m_showReadyPulse; } + quint64 indexedFilesCount() const { return m_indexedFilesCount; } + QString emptyTitle() const; + QString emptyDescription() const; + private: void renderRecentFiles(); + void clearSection(); void handleDebounce(); void handleSearchResults(); + void handleScanState(quint64 scanned, bool scanning, bool ready); void loadDetail(const std::filesystem::path &path); void clearDetail(); @@ -56,4 +71,13 @@ class SearchFilesViewHost : public ListViewHost { QString m_detailLastModified; QString m_detailImageSource; QString m_detailTextContent; + + bool m_isIndexing = false; + bool m_indexReady = false; + // Brief pulse window after the scan flips to ready, used to render a + // "Ready — N files indexed" message before recents take over. + bool m_showReadyPulse = false; + quint64 m_indexedFilesCount = 0; + quint64 m_readyAnnounceCount = 0; + QTimer m_readyPulseTimer; }; diff --git a/src/server/src/services/files-service/abstract-file-indexer.hpp b/src/server/src/services/files-service/abstract-file-indexer.hpp index 3b51d37b1..dd55147d1 100644 --- a/src/server/src/services/files-service/abstract-file-indexer.hpp +++ b/src/server/src/services/files-service/abstract-file-indexer.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -39,17 +40,38 @@ struct Pagination { }; class AbstractFileIndexer : public QObject { + Q_OBJECT + +signals: + /** Emitted when underlying indexer changes the amount of scanned files */ + void scanStateChanged(quint64 scannedFilesCount, bool isScanning, bool isReady); + public: + struct ScanState { + std::uint64_t scannedFilesCount = 0; + bool isScanning = false; + bool isReady = false; + }; + struct QueryParams { Pagination pagination; }; public: virtual void start() = 0; + + /** Clears all the in-memory file indexer */ + virtual void stop() {} + virtual void rebuildIndex() = 0; virtual void preferenceValuesChanged(const QJsonObject &preferences) = 0; virtual QFuture> queryAsync(std::string_view view, const QueryParams ¶ms = {}) = 0; - virtual ~AbstractFileIndexer() = default; + /** + * Snapshot of the current scan state. Thread-safe; may briefly contend on an internal mutex. + */ + virtual ScanState scanState() const = 0; + + ~AbstractFileIndexer() override = default; }; diff --git a/src/server/src/services/files-service/fff/fff-file-indexer.cpp b/src/server/src/services/files-service/fff/fff-file-indexer.cpp new file mode 100644 index 000000000..2b9ef6a2f --- /dev/null +++ b/src/server/src/services/files-service/fff/fff-file-indexer.cpp @@ -0,0 +1,413 @@ +#include "fff-file-indexer.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/utils.hpp" +#include "vicinae.hpp" + +namespace fs = std::filesystem; + +using FffInstance = vicinae::fff::FffInstance; + +namespace { +fs::path defaultBasePath() { + if (const char *home = std::getenv("HOME")) return fs::path(home); + return fs::current_path(); +} + +fs::path fffStateDir() { return Omnicast::dataDir() / "fff"; } + +// Resolve a directory honoring the matching XDG variable. `homeRelative` is +// the fallback (e.g. ".cache" or ".local/share") used when the env var is +// unset or empty. +fs::path xdgDir(const char *envVar, const char *homeRelative) { + if (const char *v = std::getenv(envVar); v && *v) return fs::path(v); + if (const char *home = std::getenv("HOME"); home && *home) return fs::path(home) / homeRelative; + return fs::current_path() / homeRelative; +} + +struct NvimFffPaths { + std::optional frecency; + std::optional history; +}; + +// Probe fff.nvim's default LMDB envs. A path counts as "present" only if it +// is a directory containing a `data.mdb` file (the LMDB sentinel), so we +// don't accidentally point fff at an empty directory. +NvimFffPaths detectNvimFffPaths() { + NvimFffPaths out; + + fs::path frecency = xdgDir("XDG_CACHE_HOME", ".cache") / "nvim" / "fff_nvim"; + fs::path history = xdgDir("XDG_DATA_HOME", ".local/share") / "nvim" / "fff_queries"; + + std::error_code ec; + if (fs::is_directory(frecency, ec) && fs::is_regular_file(frecency / "data.mdb", ec)) { + out.frecency = std::move(frecency); + } + ec.clear(); + if (fs::is_directory(history, ec) && fs::is_regular_file(history / "data.mdb", ec)) { + out.history = std::move(history); + } + return out; +} + +std::vector parsePaths(const QJsonObject &preferences, const QString &key) { + auto raw = preferences.value(key).toString(); + auto parts = raw.split(';', Qt::SkipEmptyParts); + return ranges_to( + parts | std::views::transform([](const QString &v) { return fs::path(v.trimmed().toStdString()); })); +} + +} // namespace + +FffFileIndexer::FffFileIndexer() : m_basePath(defaultBasePath()) { + m_progressPoll.setInterval(200); + m_progressPoll.setSingleShot(false); + QObject::connect(&m_progressPoll, &QTimer::timeout, this, &FffFileIndexer::emitProgressSnapshot); +} + +FffFileIndexer::~FffFileIndexer() { + m_progressPoll.stop(); + m_generation.fetch_add(1, std::memory_order_acq_rel); + drainPendingEmpty(); +} + +FffInstance::Config FffFileIndexer::buildConfig(bool forceLocalDbs) const { + fs::path base; + bool reuseNvim; + { + std::lock_guard lock(m_prefsMtx); + base = m_basePath.empty() ? defaultBasePath() : m_basePath; + reuseNvim = m_reuseNvimDbs && !forceLocalDbs; + } + + auto stateDir = fffStateDir(); + std::error_code ec; + fs::create_directories(stateDir, ec); + + fs::path frecencyDb = stateDir / "frecency"; + fs::path historyDb = stateDir / "history"; + + if (reuseNvim) { + auto nvim = detectNvimFffPaths(); + if (nvim.frecency) { + qInfo() << "fff: reusing fff.nvim frecency db at" << nvim.frecency->c_str(); + frecencyDb = std::move(*nvim.frecency); + } + if (nvim.history) { + qInfo() << "fff: reusing fff.nvim history db at" << nvim.history->c_str(); + historyDb = std::move(*nvim.history); + } + } + + FffInstance::Config config; + config.basePath = std::move(base); + config.frecencyDbPath = std::move(frecencyDb); + config.historyDbPath = std::move(historyDb); + config.enableMmapCache = false; + config.enableContentIndexing = false; + config.watch = false; + config.aiMode = false; + return config; +} + +std::shared_ptr FffFileIndexer::currentInstance() const { + std::lock_guard lock(m_instanceMtx); + return m_instance; +} + +void FffFileIndexer::setCurrentInstance(std::shared_ptr instance) { + std::lock_guard lock(m_instanceMtx); + m_instance = std::move(instance); +} + +void FffFileIndexer::start() { + if (currentInstance() && m_scanReady.load()) return; + + if (m_initInProgress.load()) { + // Stale spawn still draining; ask it to re-spawn on exit. + m_pendingRestart.store(true); + return; + } + + spawnInstance(); +} + +void FffFileIndexer::stop() { + m_progressPoll.stop(); + m_generation.fetch_add(1, std::memory_order_acq_rel); + setCurrentInstance(nullptr); + m_scanReady.store(false); + drainPendingEmpty(); + emit scanStateChanged(0, false, false); +} + +void FffFileIndexer::rebuildIndex() { + m_scanReady = false; + setCurrentInstance(nullptr); + m_generation.fetch_add(1, std::memory_order_acq_rel); + if (m_initInProgress.load()) { + m_pendingRestart.store(true); + return; + } + spawnInstance(); +} + +void FffFileIndexer::drainPendingEmpty() { + std::optional pending; + { + std::lock_guard lock(m_pendingMtx); + pending = std::move(m_pendingQuery); + m_pendingQuery.reset(); + } + if (!pending || !pending->promise) return; + pending->promise->start(); + pending->promise->addResult({}); + pending->promise->finish(); +} + +AbstractFileIndexer::ScanState FffFileIndexer::scanState() const { + ScanState state{}; + // `m_scanReady` is the truth source: fff's own `is_warmup_complete` is tied + // to features we disable (content indexing / watcher) and cannot be relied + // upon here. + bool const ready = m_scanReady.load(); + bool const inProgress = m_initInProgress.load(); + auto instance = currentInstance(); + + if (instance) { state.scannedFilesCount = instance->progress().scannedFilesCount; } + + state.isReady = ready; + state.isScanning = !ready && (inProgress || instance != nullptr); + return state; +} + +void FffFileIndexer::emitProgressSnapshot() { + auto state = scanState(); + if (state.scannedFilesCount == m_lastEmittedCount && state.isScanning == m_lastEmittedScanning && + state.isReady == m_lastEmittedReady) { + return; + } + m_lastEmittedCount = state.scannedFilesCount; + m_lastEmittedScanning = state.isScanning; + m_lastEmittedReady = state.isReady; + + emit scanStateChanged(static_cast(state.scannedFilesCount), state.isScanning, state.isReady); + + if (state.isReady && !m_initInProgress.load()) { m_progressPoll.stop(); } +} + +void FffFileIndexer::preferenceValuesChanged(const QJsonObject &preferences) { + auto paths = parsePaths(preferences, "paths"); + fs::path newBase; + + if (paths.empty()) { + newBase = defaultBasePath(); + } else { + newBase = paths.front(); + if (paths.size() > 1) { + qWarning() << "fff: multiple 'paths' entries configured;" << paths.size() - 1 + << "extra path(s) ignored. fff indexes a single base path. Using:" << newBase.c_str(); + } + } + + // The "reuse fff.nvim dbs" toggle defaults ON when the preference is + // missing entirely (e.g. first run before the user has touched it). + bool newReuse = preferences.contains("reuseNvimDbs") ? preferences.value("reuseNvimDbs").toBool() : true; + + bool changed = false; + { + std::lock_guard lock(m_prefsMtx); + if (m_basePath != newBase) { + m_basePath = newBase; + changed = true; + } + if (m_reuseNvimDbs != newReuse) { + m_reuseNvimDbs = newReuse; + changed = true; + } + } + + if (changed && currentInstance()) { + m_scanReady = false; + setCurrentInstance(nullptr); + m_generation.fetch_add(1, std::memory_order_acq_rel); + if (m_initInProgress.load()) { + m_pendingRestart.store(true); + } else { + spawnInstance(); + } + } +} + +void FffFileIndexer::spawnInstance() { + bool expected = false; + if (!m_initInProgress.compare_exchange_strong(expected, true)) { + m_pendingRestart.store(true); + return; + } + + auto config = buildConfig(); + // Detect whether we ended up pointing at fff.nvim's dbs (so the worker + // knows to retry with vicinae-local dbs if create() fails). + bool const usedNvimDbs = [&]() { + auto stateDir = fffStateDir(); + return config.frecencyDbPath != (stateDir / "frecency") || config.historyDbPath != (stateDir / "history"); + }(); + auto const myGen = m_generation.load(std::memory_order_acquire); + + if (!m_progressPoll.isActive()) m_progressPoll.start(); + emit scanStateChanged(0, true, false); + + QThreadPool::globalInstance()->start([this, myGen, usedNvimDbs, config = std::move(config)]() mutable { + auto created = FffInstance::create(config); + + // If we tried fff.nvim's dbs and create failed (likely a schema/version + // mismatch between vicinae's fff v0.7.0 and the user's nvim plugin), + // retry once with vicinae's own dbs so the user still gets a valid index + if (!created.has_value() && usedNvimDbs) { + qWarning() << "fff: failed to open fff.nvim dbs (" << created.error().c_str() + << "). Falling back to vicinae-local dbs."; + auto fallback = buildConfig(/*forceLocalDbs=*/true); + auto retry = FffInstance::create(fallback); + if (retry.has_value()) { + created = std::move(retry); + config = std::move(fallback); + } + } + + auto const finishWorker = [this]() { + m_initInProgress.store(false); + if (m_pendingRestart.exchange(false)) { + QMetaObject::invokeMethod(this, [this]() { spawnInstance(); }, Qt::QueuedConnection); + } else { + QMetaObject::invokeMethod(this, [this]() { emitProgressSnapshot(); }, Qt::QueuedConnection); + } + }; + + if (!created.has_value()) { + qCritical() << "fff: failed to create instance for base_path" << config.basePath.c_str() << ":" + << created.error().c_str(); + drainPendingEmpty(); + finishWorker(); + return; + } + + if (myGen != m_generation.load(std::memory_order_acquire)) { + drainPendingEmpty(); + finishWorker(); + return; + } + + std::shared_ptr instance = std::move(created.value()); + + // Expose the handle now so the main-thread poller can read live progress while the warmup runs. + setCurrentInstance(instance); + + if (!instance->waitForScan(std::chrono::milliseconds(0))) { + qWarning() << "fff: wait_for_scan did not report completion for" << config.basePath.c_str(); + } + + // stop() may have fired during the warmup. + if (myGen != m_generation.load(std::memory_order_acquire)) { + setCurrentInstance(nullptr); + drainPendingEmpty(); + finishWorker(); + return; + } + + qInfo() << "fff: index ready for" << config.basePath.c_str(); + + // important to drain the parked query under the same lock + // so another queryAsync wouldn't have a race window to partially override + std::optional pending; + { + std::lock_guard lock(m_pendingMtx); + m_scanReady = true; + pending = std::move(m_pendingQuery); + m_pendingQuery.reset(); + } + + QMetaObject::invokeMethod(this, [this]() { emitProgressSnapshot(); }, Qt::QueuedConnection); + + if (pending) { runQuery(instance, std::move(*pending)); } + + m_initInProgress.store(false); + if (m_pendingRestart.exchange(false)) { + QMetaObject::invokeMethod(this, [this]() { spawnInstance(); }, Qt::QueuedConnection); + } + }); +} + +void FffFileIndexer::runQuery(std::shared_ptr instance, PendingQuery query) const { + if (!instance || !query.promise) return; + + QThreadPool::globalInstance()->start([instance = std::move(instance), query = std::move(query)]() mutable { + FffInstance::SearchOptions opts; + opts.pageIndex = query.params.pagination.offset / std::max(1, query.params.pagination.limit); + opts.pageSize = query.params.pagination.limit > 0 ? query.params.pagination.limit : 100; + + auto results = instance->search(query.query, opts); + query.promise->start(); + query.promise->addResult(std::move(results)); + query.promise->finish(); + }); +} + +QFuture> FffFileIndexer::queryAsync(std::string_view view, + const QueryParams ¶ms) { + auto promise = std::make_shared>>(); + auto future = promise->future(); + + PendingQuery pending{.query = std::string(view), .params = params, .promise = promise}; + + // Three states to handle: + // - scan ready -> dispatch immediatly on the current instance + // - scan in progress -> park the query, superseding any older parked query + // - not started -> do nothing, we require explicit ::start() to be called to trigger indexer + std::shared_ptr instance; + std::optional superseded; + bool parked = false; + + { + std::lock_guard lock(m_pendingMtx); + if (m_scanReady.load()) { + instance = currentInstance(); + } else if (m_initInProgress.load()) { + superseded = std::move(m_pendingQuery); + m_pendingQuery = std::move(pending); + parked = true; + } + } + + if (superseded && superseded->promise) { + superseded->promise->start(); + superseded->promise->addResult({}); + superseded->promise->finish(); + } + + if (parked) return future; + + // if we already started run the actual search + if (instance) { + runQuery(std::move(instance), std::move(pending)); + return future; + } + + promise->start(); + promise->addResult({}); + promise->finish(); + return future; +} diff --git a/src/server/src/services/files-service/fff/fff-file-indexer.hpp b/src/server/src/services/files-service/fff/fff-file-indexer.hpp new file mode 100644 index 000000000..72aeaec69 --- /dev/null +++ b/src/server/src/services/files-service/fff/fff-file-indexer.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "services/files-service/abstract-file-indexer.hpp" +#include "services/files-service/fff/fff-library.hpp" + +/** + * `AbstractFileIndexer` backed by the fff C library (path index only; + * content indexing, grep, and the fff watcher are disabled). + * + * Dormant until `start()` is called. The first `start()` triggers a + * background scan; queries issued during warmup are parked so the user can + * keep typing freely. Only the most recent query survives — older parked + * queries are resolved with empty results as soon as they are superseded, + * and the surviving one is dispatched once the scan completes. Queries + * issued before `start()` resolve synchronously with an empty result, so + * callers can opt out of forcing a scan. + */ +class FffFileIndexer : public AbstractFileIndexer { + Q_OBJECT + +public: + FffFileIndexer(); + ~FffFileIndexer() override; + + void start() override; + void stop() override; + void rebuildIndex() override; + void preferenceValuesChanged(const QJsonObject &preferences) override; + QFuture> queryAsync(std::string_view view, + const QueryParams ¶ms = {}) override; + + ScanState scanState() const override; + +private: + struct PendingQuery { + std::string query; + QueryParams params; + std::shared_ptr>> promise; + }; + + vicinae::fff::FffInstance::Config buildConfig(bool forceLocalDbs = false) const; + + void spawnInstance(); + void runQuery(std::shared_ptr instance, PendingQuery query) const; + + std::shared_ptr currentInstance() const; + void setCurrentInstance(std::shared_ptr instance); + + void drainPendingEmpty(); + void emitProgressSnapshot(); + + mutable std::mutex m_instanceMtx; + std::shared_ptr m_instance; + + std::atomic m_scanReady{false}; + std::atomic m_initInProgress{false}; + // Bumped on every `stop()` so any in-flight spawn worker can detect that + // its FffInstance has been orphaned and drop it. + std::atomic m_generation{0}; + // Set when a `start()` lands while a stale spawn is still draining; the + // outgoing worker re-issues a fresh `spawnInstance()` once it exits. + std::atomic m_pendingRestart{false}; + + mutable std::mutex m_pendingMtx; + std::optional m_pendingQuery; + + mutable std::mutex m_prefsMtx; + std::filesystem::path m_basePath; + // When true (default), buildConfig() consults `detectNvimFffPaths()` and + // points the fff instance at fff.nvim's frecency / history LMDB envs if + // they exist. Toggled via the `reuseNvimDbs` preference. + bool m_reuseNvimDbs = true; + + QTimer m_progressPoll; + + std::uint64_t m_lastEmittedCount = 0; + bool m_lastEmittedScanning = false; + bool m_lastEmittedReady = false; +}; diff --git a/src/server/src/services/files-service/fff/fff-library.cpp b/src/server/src/services/files-service/fff/fff-library.cpp new file mode 100644 index 000000000..00f1faef8 --- /dev/null +++ b/src/server/src/services/files-service/fff/fff-library.cpp @@ -0,0 +1,186 @@ +#include "fff-library.hpp" + +extern "C" { +#include +} + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace vicinae::fff { + +namespace { + +struct FffResultDeleter { + void operator()(::FffResult *r) const noexcept { + if (r) fff_free_result(r); + } +}; +using FffResultPtr = std::unique_ptr<::FffResult, FffResultDeleter>; + +struct FffSearchResultDeleter { + void operator()(::FffSearchResult *r) const noexcept { + if (r) fff_free_search_result(r); + } +}; +using FffSearchResultPtr = std::unique_ptr<::FffSearchResult, FffSearchResultDeleter>; + +struct FffScanProgressDeleter { + void operator()(::FffScanProgress *r) const noexcept { + if (r) fff_free_scan_progress(r); + } +}; +using FffScanProgressPtr = std::unique_ptr<::FffScanProgress, FffScanProgressDeleter>; + +std::string takeError(::FffResult *result) { + if (!result || !result->error) return "unknown fff error"; + return std::string(result->error); +} + +const char *cstr(const fs::path &p) { return p.empty() ? "" : p.c_str(); } + +} // namespace + +FffInstance::FffInstance(void *handle, fs::path basePath) + : m_handle(handle), m_basePath(std::move(basePath)) {} + +FffInstance::FffInstance(FffInstance &&other) noexcept + : m_handle(other.m_handle), m_basePath(std::move(other.m_basePath)) { + other.m_handle = nullptr; +} + +FffInstance &FffInstance::operator=(FffInstance &&other) noexcept { + if (this == &other) return *this; + if (m_handle) fff_destroy(m_handle); + m_handle = other.m_handle; + m_basePath = std::move(other.m_basePath); + other.m_handle = nullptr; + return *this; +} + +FffInstance::~FffInstance() { + if (m_handle) fff_destroy(m_handle); +} + +std::expected, std::string> FffInstance::create(const Config &config) { + if (config.basePath.empty()) { return std::unexpected("fff: base_path must not be empty"); } + + FffResultPtr result(fff_create_instance(config.basePath.c_str(), cstr(config.frecencyDbPath), + cstr(config.historyDbPath), + /*use_unsafe_no_lock=*/false, config.enableMmapCache, + config.enableContentIndexing, config.watch, config.aiMode)); + + if (!result || !result->success || !result->handle) { return std::unexpected(takeError(result.get())); } + + void *handle = result->handle; + // Ownership of handle is transferred to us; FffResult itself is freed by + // the unique_ptr deleter. fff_free_result does *not* free the handle. + return std::unique_ptr(new FffInstance(handle, config.basePath)); +} + +bool FffInstance::waitForScan(std::chrono::milliseconds timeout) const { + if (!m_handle) return false; + + std::uint64_t const timeoutMs = timeout.count() > 0 ? static_cast(timeout.count()) + : std::numeric_limits::max(); + + FffResultPtr result(fff_wait_for_scan(m_handle, timeoutMs)); + if (!result || !result->success) { + qWarning() << "fff: wait_for_scan failed:" << (result ? result->error : "null result"); + return false; + } + return result->int_value == 1; +} + +FffInstance::ScanProgress FffInstance::progress() const { + ScanProgress out{}; + if (!m_handle) return out; + + FffResultPtr result(fff_get_scan_progress(m_handle)); + if (!result || !result->success || !result->handle) { + if (result && result->error) { qWarning() << "fff: get_scan_progress failed:" << result->error; } + return out; + } + + auto *raw = static_cast<::FffScanProgress *>(result->handle); + FffScanProgressPtr owned(raw); + + out.scannedFilesCount = owned->scanned_files_count; + out.isScanning = owned->is_scanning; + out.isWatcherReady = owned->is_watcher_ready; + out.isWarmupComplete = owned->is_warmup_complete; + return out; +} + +std::vector FffInstance::search(std::string_view query, + const SearchOptions &options) const { + std::vector out; + if (!m_handle) return out; + + // fff_search takes C strings; copy into a null-terminated buffer. + std::string const queryStr(query); + + FffResultPtr result(fff_search(m_handle, queryStr.c_str(), /*current_file=*/"", + static_cast(std::max(0, options.maxThreads)), + static_cast(std::max(0, options.pageIndex)), + static_cast(std::max(0, options.pageSize)), + /*combo_boost_multiplier=*/0, /*min_combo_count=*/0)); + if (!result || !result->success || !result->handle) { + if (result && result->error) { qWarning() << "fff: search failed:" << result->error; } + return out; + } + + auto *rawSearch = static_cast<::FffSearchResult *>(result->handle); + FffSearchResultPtr owned(rawSearch); + out.reserve(owned->count); + + for (std::uint32_t i = 0; i < owned->count; ++i) { + const ::FffFileItem *item = fff_search_result_get_item(owned.get(), i); + const ::FffScore *score = fff_search_result_get_score(owned.get(), i); + if (!item) continue; + + const char *relative = fff_file_item_get_relative_path(item); + if (!relative) continue; + + fs::path absolute = m_basePath / relative; + double rank = score ? static_cast(score->total) : 0.0; + + out.push_back(IndexerFileResult{.path = std::move(absolute), .rank = rank}); + } + + return out; +} + +bool FffInstance::rescan() { + if (!m_handle) return false; + FffResultPtr result(fff_scan_files(m_handle)); + if (!result || !result->success) { + qWarning() << "fff: scan_files failed:" << (result ? result->error : "null result"); + return false; + } + return true; +} + +bool FffInstance::restartIndex(const fs::path &newBase) { + if (!m_handle) return false; + FffResultPtr result(fff_restart_index(m_handle, newBase.c_str())); + if (!result || !result->success) { + qWarning() << "fff: restart_index failed:" << (result ? result->error : "null result"); + return false; + } + m_basePath = newBase; + return true; +} + +} // namespace vicinae::fff diff --git a/src/server/src/services/files-service/fff/fff-library.hpp b/src/server/src/services/files-service/fff/fff-library.hpp new file mode 100644 index 000000000..afe1e0fcb --- /dev/null +++ b/src/server/src/services/files-service/fff/fff-library.hpp @@ -0,0 +1,91 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "services/files-service/abstract-file-indexer.hpp" + +namespace vicinae::fff { + +/** + * Thin RAII wrapper over the fff C API (https://github.com/dmtrKovalenko/fff, + * `crates/fff-c`). Scoped to what vicinae needs for path search: create an + * instance, wait for the initial scan, run fuzzy searches, optionally swap + * the root directory. We intentionally don't expose grep / content indexing. + * + * Thread safety: the underlying fff instance is internally synchronized, so + * `search()` and `progress()` may be called from any thread for as long as + * the object lives. Destruction must be race-free with any in-flight + * callers; in practice the owning `FffFileIndexer` pins the shared_ptr for + * the duration of each query. + */ +class FffInstance { +public: + struct Config { + std::filesystem::path basePath; + std::filesystem::path frecencyDbPath; + std::filesystem::path historyDbPath; + bool enableMmapCache = false; + bool enableContentIndexing = false; + bool watch = false; + bool aiMode = false; + }; + + struct ScanProgress { + std::uint64_t scannedFilesCount = 0; + bool isScanning = false; + bool isWatcherReady = false; + bool isWarmupComplete = false; + }; + + struct SearchOptions { + int pageIndex = 0; + int pageSize = 100; + int maxThreads = 0; + }; + + static std::expected, std::string> create(const Config &config); + + FffInstance(const FffInstance &) = delete; + FffInstance &operator=(const FffInstance &) = delete; + FffInstance(FffInstance &&other) noexcept; + FffInstance &operator=(FffInstance &&other) noexcept; + ~FffInstance(); + + /** + * Block the calling thread until the background scan completes (or the + * timeout elapses). Safe to call from a worker thread. Returns true if + * the scan completed, false on timeout / error. + */ + bool waitForScan(std::chrono::milliseconds timeout = std::chrono::milliseconds(0)) const; + + ScanProgress progress() const; + + std::vector search(std::string_view query, const SearchOptions &options) const; + + /** + * Kick off a rescan of the configured base_path. Results of in-flight + * searches are undefined; callers should treat this as a readiness reset. + */ + bool rescan(); + + /** + * Swap the indexed base path. The instance enters a not-ready state again; + * call `waitForScan` to block until the new warmup finishes. + */ + bool restartIndex(const std::filesystem::path &newBase); + + const std::filesystem::path &basePath() const { return m_basePath; } + +private: + FffInstance(void *handle, std::filesystem::path basePath); + + void *m_handle = nullptr; + std::filesystem::path m_basePath; +}; + +} // namespace vicinae::fff diff --git a/src/server/src/services/files-service/file-indexer/file-indexer.cpp b/src/server/src/services/files-service/file-indexer/file-indexer.cpp index 018f7f226..2f6599e30 100644 --- a/src/server/src/services/files-service/file-indexer/file-indexer.cpp +++ b/src/server/src/services/files-service/file-indexer/file-indexer.cpp @@ -130,6 +130,8 @@ QFuture> FileIndexer::queryAsync(std::string_view return m_queryEngine.query(view, params); } +AbstractFileIndexer::ScanState FileIndexer::scanState() const { return {}; } + FileIndexer::FileIndexer() : m_writer(std::make_shared()), m_dispatcher(m_writer) { m_db.runMigrations(); } diff --git a/src/server/src/services/files-service/file-indexer/file-indexer.hpp b/src/server/src/services/files-service/file-indexer/file-indexer.hpp index 9f58817fa..d91af607a 100644 --- a/src/server/src/services/files-service/file-indexer/file-indexer.hpp +++ b/src/server/src/services/files-service/file-indexer/file-indexer.hpp @@ -45,6 +45,7 @@ class FileIndexer : public AbstractFileIndexer { QFuture> queryAsync(std::string_view view, const QueryParams ¶ms = {}) override; void start() override; + ScanState scanState() const override; FileIndexer(); }; diff --git a/src/server/src/services/files-service/file-service.cpp b/src/server/src/services/files-service/file-service.cpp index d5dec41a7..2323f7042 100644 --- a/src/server/src/services/files-service/file-service.cpp +++ b/src/server/src/services/files-service/file-service.cpp @@ -1,12 +1,8 @@ #include "omni-database.hpp" #include "services/files-service/abstract-file-indexer.hpp" +#include "services/files-service/fff/fff-file-indexer.hpp" #include #include "file-service.hpp" -#ifdef Q_OS_LINUX -#include "file-indexer/file-indexer.hpp" -#else -#include "dummy-file-indexer.hpp" -#endif namespace fs = std::filesystem; @@ -72,10 +68,4 @@ void FileService::preferenceValuesChanged(const QJsonObject &preferences) { m_indexer->preferenceValuesChanged(preferences); } -FileService::FileService(OmniDatabase &db) : m_db(db) { -#ifdef Q_OS_LINUX - m_indexer = std::make_unique(); -#else - m_indexer = std::make_unique(); -#endif -} +FileService::FileService(OmniDatabase &db) : m_db(db) { m_indexer = std::make_unique(); } diff --git a/src/server/tests/fff-smoke.cpp b/src/server/tests/fff-smoke.cpp new file mode 100644 index 000000000..6246d1a0e --- /dev/null +++ b/src/server/tests/fff-smoke.cpp @@ -0,0 +1,254 @@ +// Headless smoke test for the FffFileIndexer integration. +// +// Builds a small indexer pointed at the vicinae repo, waits for the scan to +// finish, runs a query for a path we know must exist, and asserts that at +// least one result came back. Exits non-zero on failure. +// +// Not part of the main executable. Built only when -DBUILD_FFF_SMOKE=ON. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "services/files-service/abstract-file-indexer.hpp" +#include "services/files-service/fff/fff-file-indexer.hpp" +#include "services/files-service/fff/fff-library.hpp" + +namespace fs = std::filesystem; + +namespace { + +#define LOG(fmt, ...) \ + do { \ + std::printf("[smoke] " fmt "\n", ##__VA_ARGS__); \ + std::fflush(stdout); \ + } while (0) +#define FAIL(fmt, ...) \ + do { \ + std::fprintf(stderr, "[smoke][FAIL] " fmt "\n", ##__VA_ARGS__); \ + std::fflush(stderr); \ + } while (0) + +int run(QCoreApplication &app, const fs::path &base) { + using namespace std::chrono_literals; + + LOG("base_path = %s", base.c_str()); + + // 1. Exercise the low-level RAII wrapper directly. + { + vicinae::fff::FffInstance::Config cfg; + cfg.basePath = base; + cfg.frecencyDbPath = ""; + cfg.historyDbPath = ""; + + auto created = vicinae::fff::FffInstance::create(cfg); + if (!created.has_value()) { + FAIL("FffInstance::create failed: %s", created.error().c_str()); + return 1; + } + + auto &instance = created.value(); + LOG("waiting for initial scan (up to 60s)..."); + if (!instance->waitForScan(60s)) { + FAIL("wait_for_scan timed out"); + return 1; + } + + auto progress = instance->progress(); + LOG("scanned=%llu warmup_complete=%d", static_cast(progress.scannedFilesCount), + progress.isWarmupComplete ? 1 : 0); + + vicinae::fff::FffInstance::SearchOptions opts; + opts.pageSize = 20; + auto direct = instance->search("fff-library", opts); + LOG("FffInstance::search(\"fff-library\") returned %zu results", direct.size()); + + bool foundHpp = false; + bool foundCpp = false; + for (const auto &r : direct) { + auto name = r.path.filename().string(); + LOG(" - %s (rank=%.1f)", r.path.c_str(), r.rank); + if (name == "fff-library.hpp") foundHpp = true; + if (name == "fff-library.cpp") foundCpp = true; + } + if (!foundHpp || !foundCpp) { + FAIL("expected fff-library.{hpp,cpp} via FffInstance; hpp=%d cpp=%d", foundHpp, foundCpp); + return 1; + } + LOG("PASS: FffInstance direct search"); + } + + // 2. Exercise the QObject-based FffFileIndexer end-to-end. + auto indexer = std::make_unique(); + + QJsonObject prefs; + prefs.insert("paths", QString::fromStdString(base.string())); + indexer->preferenceValuesChanged(prefs); + indexer->start(); + + int exitCode = 0; + bool completed = false; + + QFutureWatcher> watcher; + QObject::connect(&watcher, &QFutureWatcher>::finished, &app, [&]() { + auto results = watcher.result(); + LOG("FffFileIndexer::queryAsync returned %zu results", results.size()); + + bool found = false; + for (const auto &r : results) { + LOG(" - %s (rank=%.1f)", r.path.c_str(), r.rank); + if (r.path.filename() == "fff-file-indexer.cpp") { found = true; } + } + + if (!found) { + FAIL("FffFileIndexer did not return fff-file-indexer.cpp"); + exitCode = 1; + } else { + LOG("PASS: FffFileIndexer queryAsync"); + } + + completed = true; + app.quit(); + }); + + // Overall budget: 90 seconds. First open of fff on a cold tree can be slow. + QTimer::singleShot(90'000, &app, [&]() { + if (!completed) { + FAIL("indexer query did not complete within timeout"); + exitCode = 1; + app.quit(); + } + }); + + LOG("kicking off FffFileIndexer::queryAsync(\"fff-file-indexer\")"); + auto future = indexer->queryAsync("fff-file-indexer"); + watcher.setFuture(future); + + app.exec(); + return exitCode; +} + +// Verifies the "park latest, drop older" behavior of FffFileIndexer. +// Strategy: start() the indexer, then synchronously fire 3 queries before the +// worker thread can flip m_scanReady. With the supersede semantics, q1 and q2 +// should resolve with empty results (they were displaced) and q3 should be +// dispatched once the scan finishes. +int runSupersedeTest(QCoreApplication &app, const fs::path &base) { + LOG("--- supersede test: 3 queries during warmup ---"); + + auto indexer = std::make_unique(); + QJsonObject prefs; + prefs.insert("paths", QString::fromStdString(base.string())); + indexer->preferenceValuesChanged(prefs); + indexer->start(); + + // Fire 3 queries back-to-back. Because spawnInstance() sets + // m_initInProgress=true synchronously before the worker is queued, all 3 + // calls land while the indexer is still warming up. + auto fut1 = indexer->queryAsync("fff-file-indexer"); + auto fut2 = indexer->queryAsync("fff-library"); + auto fut3 = indexer->queryAsync("fff-file-indexer"); + + int exitCode = 0; + bool done1 = false, done2 = false, done3 = false; + std::size_t n1 = 0, n2 = 0, n3 = 0; + bool latestFoundExpected = false; + + auto checkAllDone = [&]() { + if (!(done1 && done2 && done3)) return; + + LOG("supersede: q1 (superseded) -> %zu results", n1); + LOG("supersede: q2 (superseded) -> %zu results", n2); + LOG("supersede: q3 (latest) -> %zu results", n3); + + bool ok = true; + if (n1 != 0) { + FAIL("expected q1 (superseded) to return 0 results, got %zu", n1); + ok = false; + } + if (n2 != 0) { + FAIL("expected q2 (superseded) to return 0 results, got %zu", n2); + ok = false; + } + if (n3 == 0) { + FAIL("expected q3 (latest) to return some results, got 0"); + ok = false; + } + if (!latestFoundExpected) { + FAIL("expected q3 results to contain fff-file-indexer.cpp"); + ok = false; + } + if (ok) { + LOG("PASS: supersede behavior verified"); + } else { + exitCode = 1; + } + app.quit(); + }; + + using Watcher = QFutureWatcher>; + Watcher w1, w2, w3; + + QObject::connect(&w1, &Watcher::finished, &app, [&]() { + n1 = w1.result().size(); + done1 = true; + checkAllDone(); + }); + QObject::connect(&w2, &Watcher::finished, &app, [&]() { + n2 = w2.result().size(); + done2 = true; + checkAllDone(); + }); + QObject::connect(&w3, &Watcher::finished, &app, [&]() { + auto results = w3.result(); + n3 = results.size(); + for (const auto &r : results) { + if (r.path.filename() == "fff-file-indexer.cpp") { + latestFoundExpected = true; + break; + } + } + done3 = true; + checkAllDone(); + }); + + w1.setFuture(fut1); + w2.setFuture(fut2); + w3.setFuture(fut3); + + QTimer::singleShot(90'000, &app, [&]() { + if (!(done1 && done2 && done3)) { + FAIL("supersede test did not complete within timeout (done1=%d done2=%d done3=%d)", done1, done2, + done3); + exitCode = 1; + app.quit(); + } + }); + + app.exec(); + return exitCode; +} + +} // namespace + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + + fs::path base = argc >= 2 ? fs::path(argv[1]) : fs::current_path(); + std::printf("fff smoke: starting (base=%s)\n", base.c_str()); + std::fflush(stdout); + int rc = run(app, base); + if (rc == 0) { rc = runSupersedeTest(app, base); } + std::printf("fff smoke: done rc=%d\n", rc); + std::fflush(stdout); + return rc; +} diff --git a/src/typescript/api/package-lock.json b/src/typescript/api/package-lock.json index 034502c94..003442bb8 100644 --- a/src/typescript/api/package-lock.json +++ b/src/typescript/api/package-lock.json @@ -906,7 +906,6 @@ "integrity": "sha512-dNWY8msnYB2a+7Audha+aTF1Pu3euiE7ySp53w8kEsXoYw7dMouV5A1UsTUY345aB152RHnmRMDiovuBi7BD+w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.12.0", "lunr": "^2.3.9", @@ -957,7 +956,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/typescript/api/src/api/proto/api.ts b/src/typescript/api/src/api/proto/api.ts index dc4d2e503..b7e7a2390 100644 --- a/src/typescript/api/src/api/proto/api.ts +++ b/src/typescript/api/src/api/proto/api.ts @@ -1,3 +1,4 @@ + // generated by vicinae figura codegen - do not edit manually interface JsonRpcMessage { @@ -18,7 +19,7 @@ type EventSubscription = { }; export class RpcTransport { - constructor(private readonly transport: ITransport) {} + constructor(private readonly transport: ITransport) { } dispatchMessage(data: string) { const msg = JSON.parse(data) as JsonRpcMessage; @@ -45,7 +46,7 @@ export class RpcTransport { this.requestMap.set(id, { resolve: (msg) => resolve(msg as T), reject }); }); - this.sendMessage({ jsonrpc: "2.0", id, method, params }); + this.sendMessage({ jsonrpc: '2.0', id, method, params }); return promise; } @@ -71,28 +72,26 @@ export class RpcTransport { this.transport.send(JSON.stringify(msg)); } + private id = 1; - private requestMap = new Map< - number, - { resolve: (value: any) => void; reject: (error: any) => void } - >(); - private handlers = new Map void>>(); -} + private requestMap = new Map void, reject: (error: any) => void }>; + private handlers = new Map void>>; +}; -export type ImageMask = "None" | "Circle" | "RoundedRectangle"; +export type ImageMask = 'None' | 'Circle' | 'RoundedRectangle'; -export type ToastStyle = "Success" | "Info" | "Warning" | "Error" | "Dynamic"; +export type ToastStyle = 'Success' | 'Info' | 'Warning' | 'Error' | 'Dynamic'; -export type PopToRootType = "Default" | "Immediate" | "Suspended"; +export type PopToRootType = 'Default' | 'Immediate' | 'Suspended'; -export type ConfirmAlertActionStyle = "Default" | "Destructive" | "Cancel"; +export type ConfirmAlertActionStyle = 'Default' | 'Destructive' | 'Cancel'; export type Application = { id: string; name: string; icon?: string; path: string; -}; +} export type RunInTerminalPayload = { cmdline: string[]; @@ -100,40 +99,40 @@ export type RunInTerminalPayload = { appId?: string; workingDirectory?: string; title?: string; -}; +} export type ThemedImageSource = { light?: string; dark?: string; -}; +} export type ImageSource = { raw?: string; themed?: ThemedImageSource; -}; +} export type DynamicColor = { light: string; dark: string; adjustContrast?: boolean; -}; +} export type ColorLike = { raw?: string; dynamic?: DynamicColor; -}; +} export type Image = { source: ImageSource; fallback?: ImageSource; mask?: ImageMask; tintColor?: ColorLike; -}; +} export type ConfirmAlertAction = { title: string; style: ConfirmAlertActionStyle; -}; +} export type ConfirmAlertPayload = { title: string; @@ -142,14 +141,14 @@ export type ConfirmAlertPayload = { dismissAction: ConfirmAlertAction; rememberUserChoice: boolean; icon?: Image; -}; +} export type Rect = { x: number; y: number; width: number; height: number; -}; +} export type Window = { id: string; @@ -159,7 +158,7 @@ export type Window = { fullscreen: boolean; bounds: Rect; app?: Application; -}; +} export type Screen = { name: string; @@ -167,7 +166,7 @@ export type Screen = { make: string; serial?: string; bounds: Rect; -}; +} export type Workspace = { id: string; @@ -175,42 +174,42 @@ export type Workspace = { active: boolean; fullscreen: boolean; monitor: string; -}; +} export type ClipboardContent = { text?: string; html?: string; path?: string; -}; +} export type ClipboardOptions = { concealed: boolean; -}; +} export type FileInfo = { path: string; mimeType: string; -}; +} export type UpdateCommandMetadataPayload = { subtitle?: string; -}; +} export type PKCEClientOptions = { name: string; id?: string; description: string; icon?: Image; -}; +} export type AuthorizeRequest = { client: PKCEClientOptions; url: string; -}; +} export type AuthorizeResponse = { code: string; -}; +} export type TokenSet = { accessToken: string; @@ -219,7 +218,7 @@ export type TokenSet = { expiresIn?: number; scope?: string; updatedAt: number; -}; +} export type SetTokensRequest = { providerId?: string; @@ -228,116 +227,93 @@ export type SetTokensRequest = { idToken?: string; expiresIn?: number; scope?: string; -}; +} export type TokenSetResponse = { set?: TokenSet; -}; +} class ApplicationService { constructor(private readonly transport: RpcTransport) {} list(target?: string): Promise { - return this.transport.request("Application/list", { target }); + return this.transport.request("Application/list", { target}); } open(target: string, appId?: string): Promise { - return this.transport.request("Application/open", { target, appId }); + return this.transport.request("Application/open", { target, appId}); } getDefault(target: string): Promise { - return this.transport.request("Application/getDefault", { target }); + return this.transport.request("Application/getDefault", { target}); } showInFileBrowser(target: string, select: boolean): Promise { - return this.transport.request("Application/showInFileBrowser", { - target, - select, - }); + return this.transport.request("Application/showInFileBrowser", { target, select}); } runInTerminal(opts: RunInTerminalPayload): Promise { - return this.transport.request("Application/runInTerminal", { opts }); + return this.transport.request("Application/runInTerminal", { opts}); } + } class UIService { constructor(private readonly transport: RpcTransport) {} render(json: string): Promise { - return this.transport.request("UI/render", { json }); - } - - showToast( - id: string, - title: string, - message: string, - style: ToastStyle, - ): Promise { - return this.transport.request("UI/showToast", { - id, - title, - message, - style, - }); + return this.transport.request("UI/render", { json}); + } + + showToast(id: string, title: string, message: string, style: ToastStyle): Promise { + return this.transport.request("UI/showToast", { id, title, message, style}); } updateToast(id: string, title: string): Promise { - return this.transport.request("UI/updateToast", { id, title }); + return this.transport.request("UI/updateToast", { id, title}); } hideToast(id: string): Promise { - return this.transport.request("UI/hideToast", { id }); - } - - showHud( - text: string, - clear_root: boolean, - popToRoot: PopToRootType, - ): Promise { - return this.transport.request("UI/showHud", { - text, - clear_root, - popToRoot, - }); + return this.transport.request("UI/hideToast", { id}); + } + + showHud(text: string, clear_root: boolean, popToRoot: PopToRootType): Promise { + return this.transport.request("UI/showHud", { text, clear_root, popToRoot}); } closeMainWindow(clearRoot: boolean, popToRoot: PopToRootType): Promise { - return this.transport.request("UI/closeMainWindow", { - clearRoot, - popToRoot, - }); + return this.transport.request("UI/closeMainWindow", { clearRoot, popToRoot}); } popToRoot(clearSearchBar: boolean): Promise { - return this.transport.request("UI/popToRoot", { clearSearchBar }); + return this.transport.request("UI/popToRoot", { clearSearchBar}); } confirmAlert(payload: ConfirmAlertPayload): Promise { - return this.transport.request("UI/confirmAlert", { payload }); + return this.transport.request("UI/confirmAlert", { payload}); } pushView(): Promise { - return this.transport.request("UI/pushView", {}); + return this.transport.request("UI/pushView", { }); } popView(): Promise { - return this.transport.request("UI/popView", {}); + return this.transport.request("UI/popView", { }); } setSearchText(text: string): Promise { - return this.transport.request("UI/setSearchText", { text }); + return this.transport.request("UI/setSearchText", { text}); } getSelectedText(): Promise { - return this.transport.request("UI/getSelectedText", {}); + return this.transport.request("UI/getSelectedText", { }); } viewPoped(handler: () => void): EventSubscription { - return this.transport.subscribe("UI/viewPoped", (msg) => handler()); + return this.transport.subscribe("UI/viewPoped", (msg) => handler()) } viewPushed(handler: () => void): EventSubscription { - return this.transport.subscribe("UI/viewPushed", (msg) => handler()); + return this.transport.subscribe("UI/viewPushed", (msg) => handler()) } } @@ -345,136 +321,133 @@ class WindowManagementService { constructor(private readonly transport: RpcTransport) {} focusWindow(winId: string): Promise { - return this.transport.request("WindowManagement/focusWindow", { winId }); + return this.transport.request("WindowManagement/focusWindow", { winId}); } getActiveWindow(): Promise { - return this.transport.request("WindowManagement/getActiveWindow", {}); + return this.transport.request("WindowManagement/getActiveWindow", { }); } getActiveWorkspace(): Promise { - return this.transport.request("WindowManagement/getActiveWorkspace", {}); + return this.transport.request("WindowManagement/getActiveWorkspace", { }); } getWindows(workspaceId?: string): Promise { - return this.transport.request("WindowManagement/getWindows", { - workspaceId, - }); + return this.transport.request("WindowManagement/getWindows", { workspaceId}); } getScreens(): Promise { - return this.transport.request("WindowManagement/getScreens", {}); + return this.transport.request("WindowManagement/getScreens", { }); } getWorkspaces(): Promise { - return this.transport.request("WindowManagement/getWorkspaces", {}); + return this.transport.request("WindowManagement/getWorkspaces", { }); } setWindowBounds(winId: string, bounds: Rect): Promise { - return this.transport.request("WindowManagement/setWindowBounds", { - winId, - bounds, - }); + return this.transport.request("WindowManagement/setWindowBounds", { winId, bounds}); } + } class ClipboardService { constructor(private readonly transport: RpcTransport) {} copy(content: ClipboardContent, options: ClipboardOptions): Promise { - return this.transport.request("Clipboard/copy", { content, options }); + return this.transport.request("Clipboard/copy", { content, options}); } paste(content: ClipboardContent): Promise { - return this.transport.request("Clipboard/paste", { content }); + return this.transport.request("Clipboard/paste", { content}); } clear(): Promise { - return this.transport.request("Clipboard/clear", {}); + return this.transport.request("Clipboard/clear", { }); } readContent(): Promise { - return this.transport.request("Clipboard/readContent", {}); + return this.transport.request("Clipboard/readContent", { }); } + } class StorageService { constructor(private readonly transport: RpcTransport) {} get(key: string): Promise { - return this.transport.request("Storage/get", { key }); + return this.transport.request("Storage/get", { key}); } set(key: string, value: any): Promise { - return this.transport.request("Storage/set", { key, value }); + return this.transport.request("Storage/set", { key, value}); } remove(key: string): Promise { - return this.transport.request("Storage/remove", { key }); + return this.transport.request("Storage/remove", { key}); } clear(): Promise { - return this.transport.request("Storage/clear", {}); + return this.transport.request("Storage/clear", { }); } list(): Promise { - return this.transport.request("Storage/list", {}); + return this.transport.request("Storage/list", { }); } + } class FileSearchService { constructor(private readonly transport: RpcTransport) {} search(q: string): Promise { - return this.transport.request("FileSearch/search", { q }); + return this.transport.request("FileSearch/search", { q}); } + } class CommandService { constructor(private readonly transport: RpcTransport) {} updateCommandMetadata(payload: UpdateCommandMetadataPayload): Promise { - return this.transport.request("Command/updateCommandMetadata", { payload }); + return this.transport.request("Command/updateCommandMetadata", { payload}); } openExtensionPreferences(): Promise { - return this.transport.request("Command/openExtensionPreferences", {}); + return this.transport.request("Command/openExtensionPreferences", { }); } openCommandPreferences(): Promise { - return this.transport.request("Command/openCommandPreferences", {}); + return this.transport.request("Command/openCommandPreferences", { }); } + } class OAuthService { constructor(private readonly transport: RpcTransport) {} authorize(payload: AuthorizeRequest): Promise { - return this.transport.request("OAuth/authorize", { payload }); + return this.transport.request("OAuth/authorize", { payload}); } getTokens(id?: string): Promise { - return this.transport.request("OAuth/getTokens", { id }); + return this.transport.request("OAuth/getTokens", { id}); } setTokens(payload: SetTokensRequest): Promise { - return this.transport.request("OAuth/setTokens", { payload }); + return this.transport.request("OAuth/setTokens", { payload}); } removeTokens(id?: string): Promise { - return this.transport.request("OAuth/removeTokens", { id }); + return this.transport.request("OAuth/removeTokens", { id}); } + } class EventCoreService { constructor(private readonly transport: RpcTransport) {} - handlerActivated( - handler: (id: string, args: any[]) => void, - ): EventSubscription { - return this.transport.subscribe("EventCore/handlerActivated", (msg) => - handler(msg.id, msg.args), - ); + handlerActivated(handler: (id: string, args: any[]) => void): EventSubscription { + return this.transport.subscribe("EventCore/handlerActivated", (msg) => handler(msg.id, msg.args)) } } @@ -491,10 +464,8 @@ export class Client { this.EventCore = new EventCoreService(this.transport); } - route(msg: string): void { - this.transport.dispatchMessage(msg); - } - Application: ApplicationService; + route(msg: string): void { this.transport.dispatchMessage(msg); } + Application: ApplicationService; UI: UIService; WindowManagement: WindowManagementService; Clipboard: ClipboardService; @@ -503,4 +474,5 @@ export class Client { Command: CommandService; OAuth: OAuthService; EventCore: EventCoreService; + } diff --git a/src/typescript/extension-manager/package-lock.json b/src/typescript/extension-manager/package-lock.json index 28ba596cd..46e0930c3 100644 --- a/src/typescript/extension-manager/package-lock.json +++ b/src/typescript/extension-manager/package-lock.json @@ -685,7 +685,6 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" }