diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e36a8240..f2313f77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,37 +16,44 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up yazi + - name: Set up ripgrep + # it's a telescope dependency run: | - # Install yazi - test -d _yazi || { - mkdir -p _yazi - wget "https://github.com/sxyazi/yazi/releases/download/v0.2.5/yazi-x86_64-unknown-linux-gnu.zip" --output-document yazi.zip - unzip yazi.zip -d _yazi + which rg || { + sudo apt-get install ripgrep } - echo "Current _yazi/ contents" - ls -R _yazi - # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path - echo "${PWD}/_yazi/yazi-x86_64-unknown-linux-gnu/" >> $GITHUB_PATH + - name: Compile and install `yazi-fm` from source + uses: baptiste0928/cargo-install@v3 + with: + # yazi-fm is the `yazi` executable + crate: yazi-fm + git: https://github.com/sxyazi/yazi + # feat: ownership linemode (#1238) + # https://github.com/sxyazi/yazi/commit/11547eefe0346006a1a82455577784a34d67c9b7 + commit: 11547eefe0346006a1a82455577784a34d67c9b7 + + - name: Compile and install yazi from source + uses: baptiste0928/cargo-install@v3 + with: + # yazi-cli is the `ya` command line interface + crate: yazi-cli + git: https://github.com/sxyazi/yazi + # feat: ownership linemode (#1238) + # https://github.com/sxyazi/yazi/commit/11547eefe0346006a1a82455577784a34d67c9b7 + commit: 11547eefe0346006a1a82455577784a34d67c9b7 - name: Run tests uses: nvim-neorocks/nvim-busted-action@v1 with: nvim_version: ${{ matrix.neovim_version }} luarocks_version: "3.11.1" - - name: Set up ripgrep - run: | - which rg || { - sudo apt-get install ripgrep - } - # Install npm dependencies, cache them correctly - # and run all Cypress tests - name: Cypress run uses: cypress-io/github-action@v6.7.1 with: command: npm run cy:run + - uses: actions/upload-artifact@v4 # add the line below to store screenshots only on failures # if: failure() diff --git a/.gitignore b/.gitignore index d9d70f0e..be71cf32 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ integration-tests/server/build/server.js integration-tests/pid.txt integration-tests/server/build *.pem +integration-tests/test-environment/testdirs diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 00000000..8d3ebd89 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,3 @@ +{ + "diagnostics.globals": ["finally"] +} diff --git a/Makefile b/Makefile index 6e9a7ebb..cd63f4af 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ init: lint: selene ./lua/ ./spec/ - @if grep -r -e "#focus" --include \*.lua .; then \ + @if grep -r -e "#focus" --include \*.lua ./spec/; then \ echo "\n"; \ echo "Error: ${COLOR_GREEN}#focus${COLOR_RESET} tags found in the codebase.\n"; \ echo "Please remove them to prevent issues with not accidentally running all tests."; \ diff --git a/README.md b/README.md index 2fb56809..a3cac7aa 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,10 @@ You can optionally configure yazi.nvim by setting any of the options below. -- } open_for_directories = false, + -- an upcoming optional feature. See + -- https://github.com/mikavilpas/yazi.nvim/pull/152 + use_ya_for_events_reading = false, + -- the floating window scaling factor. 1 means 100%, 0.9 means 90%, etc. floating_window_scaling_factor = 0.9, diff --git a/integration-tests/client/client.ts b/integration-tests/client/client.ts index 077f9387..872dd17f 100644 --- a/integration-tests/client/client.ts +++ b/integration-tests/client/client.ts @@ -6,12 +6,14 @@ import { FitAddon } from "@xterm/addon-fit" import { Terminal } from "@xterm/xterm" import io from "socket.io-client" import type { - StartAppMessage, + StartNeovimMessage, StdinMessage, StdoutMessage, } from "../server/server" -import "./startAppGlobalType" -import type { StartAppMessageArguments } from "./startAppGlobalType" +import type { + StartNeovimArguments, + StartNeovimServerArguments, +} from "./testEnvironmentTypes" const app = document.querySelector("#app") if (!app) { @@ -73,8 +75,18 @@ socket.on("disconnect", (reason) => { console.log("disconnected: ", reason) }) -window.startApp = function startApp(args: StartAppMessageArguments) { - socket.emit("startApp" satisfies StartAppMessage, args) +window.startNeovim = async function startApp( + directory: string, + startArgs?: StartNeovimArguments, +) { + await socket.emitWithAck( + "startNeovim" satisfies StartNeovimMessage, + { + directory, + filename: startArgs?.filename ?? "initial-file.txt", + startupScriptModifications: startArgs?.startupScriptModifications, + } satisfies StartNeovimServerArguments, + ) } socket.on( diff --git a/integration-tests/client/startAppGlobalType.ts b/integration-tests/client/startAppGlobalType.ts deleted file mode 100644 index a2b9e290..00000000 --- a/integration-tests/client/startAppGlobalType.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type StartAppMessageArguments = { - command: string - args: string[] -} - -declare global { - interface Window { - startApp(args: StartAppMessageArguments): void - } -} - -export {} diff --git a/integration-tests/client/testEnvironmentTypes.ts b/integration-tests/client/testEnvironmentTypes.ts new file mode 100644 index 00000000..2cb78f32 --- /dev/null +++ b/integration-tests/client/testEnvironmentTypes.ts @@ -0,0 +1,65 @@ +/** The arguments given from the tests to send to the server */ +export type StartNeovimArguments = { + filename?: TestDirectoryFile | "." + startupScriptModifications?: StartupScriptModification[] +} + +/** The arguments given to the server */ +export type StartNeovimServerArguments = { + directory: string +} & StartNeovimArguments + +export type StartupScriptModification = + "modify_yazi_config_to_use_ya_as_event_reader.lua" + +declare global { + interface Window { + startNeovim( + directory: string, + startArguments?: StartNeovimArguments, + ): Promise + } +} + +export type FileEntry = { + /** The name of the file and its extension. + * @example "file.txt" + */ + name: string + + /** The name of the file without its extension. + * @example "file" + */ + stem: string + + /** The extension of the file. + * @example ".txt" + */ + extension: string +} + +/** Describes the contents of the test directory, which is a blueprint for + * files and directories. Tests can create a unique, safe environment for + * interacting with the contents of such a directory. + * + * Having strong typing for the test directory contents ensures that tests can + * be written with confidence that the files and directories they expect are + * actually found. Otherwise the tests are brittle and can break easily. + */ +export type TestDirectory = { + /** The path to the unique test directory itself (the root). */ + rootPath: string + + contents: { + ["initial-file.txt"]: FileEntry + ["test.lua"]: FileEntry + ["file.txt"]: FileEntry + ["subdirectory/sub.txt"]: FileEntry + ["routes/posts.$postId/route.tsx"]: FileEntry + ["routes/posts.$postId/adjacent-file.tsx"]: FileEntry + } +} + +type TestDirectoryFile = keyof TestDirectory["contents"] + +export {} diff --git a/integration-tests/cypress.config.ts b/integration-tests/cypress.config.ts index 57f140dd..38c04493 100644 --- a/integration-tests/cypress.config.ts +++ b/integration-tests/cypress.config.ts @@ -1,9 +1,139 @@ +import assert from "assert" +import { execSync, Serializable } from "child_process" import { defineConfig } from "cypress" +import { constants } from "fs" +import { access, mkdir, mkdtemp, readdir, readFile, rm } from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import type { TestDirectory } from "./cypress/support/commands" + +const __dirname = fileURLToPath(new URL(".", import.meta.resolve("."))) + +// const file = "./test-environment/.repro/state/nvim/yazi.log" +const yaziLogFile = path.join( + __dirname, + "test-environment", + ".repro", + "state", + "nvim", + "yazi.log", +) + +console.log(`yaziLogFile: ${yaziLogFile}`) export default defineConfig({ e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here + setupNodeEvents(on, _config) { + on("after:browser:launch", async (): Promise => { + // delete everything under the ./test-environment/testdirs/ directory + const testdirs = path.join(__dirname, "test-environment", "testdirs") + await mkdir(testdirs, { recursive: true }) + const files = await readdir(testdirs) + + console.log("Cleaning up testdirs directory...") + + for (const file of files) { + const testdir = path.join(testdirs, file) + console.log(`Removing ${testdir}`) + await rm(testdir, { recursive: true }) + } + }) + + on("task", { + async removeYaziLog() { + try { + await rm(yaziLogFile) + } catch (err) { + if (err.code !== "ENOENT") { + console.error(err) + } + } + return null // something must be returned + }, + async showYaziLog() { + try { + const log = await readFile(yaziLogFile, "utf-8") + console.log(`${yaziLogFile}`, log.split("\n")) + return null + } catch (err) { + console.error(err) + return null // something must be returned + } + }, + async createTempDir(): Promise { + try { + const dir = await createUniqueDirectory() + + const directory: TestDirectory = { + rootPath: dir, + contents: { + "initial-file.txt": { + name: "initial-file.txt", + stem: "initial-file", + extension: ".txt", + }, + "test.lua": { + name: "test.lua", + stem: "test", + extension: ".lua", + }, + "file.txt": { + name: "file.txt", + stem: "file", + extension: ".txt", + }, + "subdirectory/sub.txt": { + name: "sub.txt", + stem: "sub", + extension: ".txt", + }, + "routes/posts.$postId/adjacent-file.tsx": { + name: "adjacent-file.tsx", + stem: "adjacent-file", + extension: ".tsx", + }, + "routes/posts.$postId/route.tsx": { + name: "route.tsx", + stem: "route", + extension: ".tsx", + }, + }, + } + directory satisfies Serializable // required by cypress + + execSync(`cp ./test-environment/initial-file.txt ${dir}/`) + execSync(`cp ./test-environment/file.txt ${dir}/`) + execSync(`cp ./test-environment/test-setup.lua ${dir}/test.lua`) + execSync(`cp -r ./test-environment/subdirectory ${dir}/`) + execSync(`cp -r ./test-environment/config-modifications/ ${dir}/`) + execSync(`cp -r ./test-environment/routes ${dir}/`) + console.log(`Created test directory at ${dir}`) + + return directory + } catch (err) { + console.error(err) + throw err + } + }, + }) + }, + retries: { + runMode: 2, + openMode: 0, }, }, }) + +async function createUniqueDirectory(): Promise { + const __dirname = fileURLToPath(new URL(".", import.meta.resolve("."))) + const testdirs = path.join(__dirname, "test-environment", "testdirs") + try { + await access(testdirs, constants.F_OK) + } catch { + await mkdir(testdirs) + } + const dir = await mkdtemp(path.join(testdirs, "dir-")) + assert(typeof dir === "string") + + return dir +} diff --git a/integration-tests/cypress/e2e/healthcheck.cy.ts b/integration-tests/cypress/e2e/healthcheck.cy.ts index ab62bf50..75cadc4f 100644 --- a/integration-tests/cypress/e2e/healthcheck.cy.ts +++ b/integration-tests/cypress/e2e/healthcheck.cy.ts @@ -1,14 +1,18 @@ +import { startNeovimWithYa } from "./using-ya-to-read-events/startNeovimWithYa" + describe("the healthcheck", () => { it("can run the :healthcheck for yazi.nvim", () => { cy.visit("http://localhost:5173") - cy.startNeovim() + startNeovimWithYa() // wait until text on the start screen is visible cy.contains("If you see this text, Neovim is ready!") cy.typeIntoTerminal(":checkhealth yazi{enter}") - // the `yazi` application should be found successfully + // the `yazi` and `ya` applications should be found successfully + cy.contains("Found yazi version 0.2.5") + cy.contains("Found ya version 0.2.5") cy.contains("OK yazi") }) }) diff --git a/integration-tests/cypress/e2e/opening-files.cy.ts b/integration-tests/cypress/e2e/opening-files.cy.ts deleted file mode 100644 index 2769852a..00000000 --- a/integration-tests/cypress/e2e/opening-files.cy.ts +++ /dev/null @@ -1,63 +0,0 @@ -describe("opening files", () => { - beforeEach(() => { - cy.visit("http://localhost:5173") - cy.startNeovim() - // wait until text on the start screen is visible - cy.contains("If you see this text, Neovim is ready!") - }) - - it("can display yazi in a floating terminal", () => { - cy.typeIntoTerminal("{upArrow}") - - // yazi should now be visible, showing the names of adjacent files - cy.contains("test-setup.lua") // an adjacent file - }) - - it("can open a file that was selected in yazi", () => { - cy.typeIntoTerminal("{upArrow}") - cy.contains("file.txt") // an adjacent file - - // search for the file in yazi. This focuses the file in yazi - cy.typeIntoTerminal("gg/file.txt{enter}") - cy.typeIntoTerminal("{enter}") - - // the file content should now be visible - cy.contains("Hello 👋") - }) - - it("can open a file in a vertical split", () => { - cy.typeIntoTerminal("{upArrow}") - cy.typeIntoTerminal("j{control+v}") - - // the file path must be visible at the bottom - cy.contains("test-environment/test-setup.lua") - cy.contains("initial-file.txt") - }) - - it("can open a file in a horizontal split", () => { - cy.typeIntoTerminal("{upArrow}") - cy.typeIntoTerminal("j{control+x}") - - // the file path must be visible at the bottom - cy.contains("test-environment/test-setup.lua") - cy.contains("initial-file.txt") - }) - - it("can send file names to the quickfix list", () => { - cy.typeIntoTerminal("{upArrow}") - cy.typeIntoTerminal("{control+a}{enter}") - - // items in the quickfix list should now be visible - cy.contains("file.txt||") - cy.contains("initial-file.txt||") - }) - - it("can grep in the current directory", () => { - cy.typeIntoTerminal("{upArrow}") - cy.typeIntoTerminal("{control+s}") - - // telescope should now be visible - cy.contains("Grep in") - cy.contains("Grep Preview") - }) -}) diff --git a/integration-tests/cypress/e2e/opening-directories.cy.ts b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-directories.cy.ts similarity index 59% rename from integration-tests/cypress/e2e/opening-directories.cy.ts rename to integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-directories.cy.ts index 338455a4..914fe1e4 100644 --- a/integration-tests/cypress/e2e/opening-directories.cy.ts +++ b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-directories.cy.ts @@ -5,10 +5,10 @@ describe("opening directories", () => { // `neovim .` specifies to open the current directory when neovim is // starting filename: ".", + }).then((dir) => { + // yazi should now be visible, showing the names of adjacent files + cy.contains(dir.contents["file.txt"].name) + cy.contains(dir.contents["initial-file.txt"].name) }) - - // yazi should now be visible, showing the names of adjacent files - cy.contains("file.txt") - cy.contains("initial-file.txt") }) }) diff --git a/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-files.cy.ts b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-files.cy.ts new file mode 100644 index 00000000..029edb30 --- /dev/null +++ b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/opening-files.cy.ts @@ -0,0 +1,95 @@ +describe("opening files", () => { + beforeEach(() => { + cy.visit("http://localhost:5173") + }) + + it("can display yazi in a floating terminal", () => { + cy.startNeovim().then((dir) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + cy.typeIntoTerminal("{upArrow}") + + // yazi should now be visible, showing the names of adjacent files + cy.contains(dir.contents["test.lua"].name) // an adjacent file + }) + }) + + it("can open a file that was selected in yazi", () => { + cy.startNeovim().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.contains(dir.contents["file.txt"].name) + + // search for the file in yazi. This focuses the file in yazi + cy.typeIntoTerminal("gg/file.txt{enter}") + cy.typeIntoTerminal("{enter}") + + // the file content should now be visible + cy.contains("Hello 👋") + }) + }) + + it("can open a file in a vertical split", () => { + cy.startNeovim().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("/test.lua{enter}") + cy.typeIntoTerminal("{control+v}") + + // the file path must be visible at the bottom + cy.contains(dir.contents["test.lua"].name) + cy.contains(dir.contents["initial-file.txt"].name) + }) + }) + + it("can open a file in a horizontal split", () => { + cy.startNeovim().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("/test.lua{enter}") + cy.typeIntoTerminal("{control+x}") + + // the file path must be visible at the bottom + cy.contains(dir.contents["test.lua"].name) + cy.contains(dir.contents["initial-file.txt"].name) + }) + }) + + it("can send file names to the quickfix list", () => { + cy.startNeovim().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("{control+a}{enter}") + + // items in the quickfix list should now be visible + cy.contains(`${dir.contents["file.txt"].name}||`) + cy.contains(`${dir.contents["initial-file.txt"].name}||`) + }) + }) + + it("can open files with complex characters in their name", () => { + cy.startNeovim().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + + // enter the routes/ directory + cy.typeIntoTerminal("/routes{enter}") + cy.typeIntoTerminal("{rightArrow}") + cy.contains(dir.contents["routes/posts.$postId/route.tsx"].name) // file in the directory + + // enter routes/posts.$postId/ + cy.typeIntoTerminal("{rightArrow}") + + // select route.tsx + cy.typeIntoTerminal( + `/${dir.contents["routes/posts.$postId/route.tsx"].name}{enter}`, + ) + + // open the file + cy.typeIntoTerminal("{enter}") + + // close yazi just to be sure the file preview is not found instead + cy.get( + dir.contents["routes/posts.$postId/adjacent-file.tsx"].name, + ).should("not.exist") + + // the file contents should now be visible + cy.contains("02c67730-6b74-4b7c-af61-fe5844fdc3d7") + }) + }) +}) diff --git a/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/reading-events.cy.ts b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/reading-events.cy.ts new file mode 100644 index 00000000..52d3b59b --- /dev/null +++ b/integration-tests/cypress/e2e/using-shell-redirection-to-read-events/reading-events.cy.ts @@ -0,0 +1,113 @@ +describe("reading events", () => { + it("can read 'cd' events and use telescope in the latest directory", () => { + cy.visit("http://localhost:5173") + cy.startNeovim() + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // move to the parent directory. This should make yazi send the "cd" event, + // indicating that the directory was changed + cy.contains("subdirectory") + cy.typeIntoTerminal("/subdirectory{enter}") + cy.typeIntoTerminal("{rightArrow}") + cy.typeIntoTerminal("{control+s}") + + // telescope should now be visible. Let's search for the contents of the + // file, which we know beforehand + cy.contains("Grep in") + cy.typeIntoTerminal("Hello") + + // we should see text indicating the search is limited to the current + // directory + cy.contains("Hello from the subdirectory! 👋") + }) +}) + +it("can read 'trash' events and close an open buffer when its file was trashed", () => { + // NOTE: trash means moving a file to the trash, not deleting it permanently + + cy.visit("http://localhost:5173") + cy.startNeovim().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file deletion + cy.typeIntoTerminal("d") + cy.contains("Move 1 selected file to trash?") + cy.typeIntoTerminal("y{enter}") + + cy.get("Move 1 selected file to trash").should("not.exist") + + // close yazi + cy.typeIntoTerminal("q") + + // internally, we should have received a trash event from yazi, and yazi.nvim should + // have closed the buffer + cy.contains(dir.contents["initial-file.txt"].name).should("not.exist") + cy.contains("If you see this text, Neovim is ready").should("not.exist") + }) +}) + +it("can read 'delete' events and close an open buffer when its file was deleted", () => { + // NOTE: delete means permanently deleting a file (not moving it to the trash) + + cy.visit("http://localhost:5173") + cy.startNeovim().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file deletion + cy.typeIntoTerminal("D") + cy.contains("Delete 1 selected file permanently?") + cy.typeIntoTerminal("y{enter}") + + cy.get("Delete 1 selected file permanently").should("not.exist") + + // close yazi + cy.typeIntoTerminal("q") + + // internally, we should have received a delete event from yazi, and yazi.nvim should + // have closed the buffer + cy.get(dir.contents["initial-file.txt"].name).should("not.exist") + cy.contains("If you see this text, Neovim is ready").should("not.exist") + }) +}) + +it("can read 'rename' events and update the buffer name when the file was renamed", () => { + cy.visit("http://localhost:5173") + cy.startNeovim().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file renaming + cy.typeIntoTerminal("r") + cy.contains("Rename:") + cy.typeIntoTerminal("2{enter}") + + cy.get("Rename").should("not.exist") + + // yazi should be showing the new file name + const file = dir.contents["initial-file.txt"] + cy.contains(`${file.stem}2${file.extension}`) + + // close yazi + cy.typeIntoTerminal("q") + + // the buffer name should now be updated + cy.contains(`${file.stem}2${file.extension}`) + }) +}) diff --git a/integration-tests/cypress/e2e/using-ya-to-read-events/opening-directories.cy.ts b/integration-tests/cypress/e2e/using-ya-to-read-events/opening-directories.cy.ts new file mode 100644 index 00000000..47b887ee --- /dev/null +++ b/integration-tests/cypress/e2e/using-ya-to-read-events/opening-directories.cy.ts @@ -0,0 +1,16 @@ +import { startNeovimWithYa } from "./startNeovimWithYa" + +describe("opening directories", () => { + it("can open a directory when starting with `neovim .`", () => { + cy.visit("http://localhost:5173") + startNeovimWithYa({ + // `neovim .` specifies to open the current directory when neovim is + // starting + filename: ".", + }).then((dir) => { + // yazi should now be visible, showing the names of adjacent files + cy.contains(dir.contents["file.txt"].name) + cy.contains(dir.contents["initial-file.txt"].name) + }) + }) +}) diff --git a/integration-tests/cypress/e2e/using-ya-to-read-events/opening-files.cy.ts b/integration-tests/cypress/e2e/using-ya-to-read-events/opening-files.cy.ts new file mode 100644 index 00000000..2a10fbbd --- /dev/null +++ b/integration-tests/cypress/e2e/using-ya-to-read-events/opening-files.cy.ts @@ -0,0 +1,124 @@ +import { startNeovimWithYa } from "./startNeovimWithYa" + +describe("opening files", () => { + beforeEach(() => { + cy.visit("http://localhost:5173") + }) + + it("can display yazi in a floating terminal", () => { + startNeovimWithYa().then((dir) => { + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + cy.typeIntoTerminal("{upArrow}") + + // yazi should now be visible, showing the names of adjacent files + cy.contains(dir.contents["test.lua"].name) // an adjacent file + }) + }) + + it("can open a file that was selected in yazi", () => { + startNeovimWithYa().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.contains(dir.contents["file.txt"].name) + + // search for the file in yazi. This focuses the file in yazi + cy.typeIntoTerminal("gg/file.txt{enter}") + cy.typeIntoTerminal("{enter}") + + // the file content should now be visible + cy.contains("Hello 👋") + }) + }) + + it("can open a file in a vertical split", () => { + startNeovimWithYa().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("/test.lua{enter}") + cy.typeIntoTerminal("{control+v}") + + // the file path must be visible at the bottom + cy.contains(dir.contents["test.lua"].name) + cy.contains(dir.contents["initial-file.txt"].name) + }) + }) + + it("can open a file in a horizontal split", () => { + startNeovimWithYa().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("/test.lua{enter}") + cy.typeIntoTerminal("{control+x}") + + // the file path must be visible at the bottom + cy.contains(dir.contents["test.lua"].name) + cy.contains(dir.contents["initial-file.txt"].name) + }) + }) + + it("can send file names to the quickfix list", () => { + startNeovimWithYa().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("{control+a}{enter}") + + // items in the quickfix list should now be visible + cy.contains(`${dir.contents["file.txt"].name}||`) + cy.contains(`${dir.contents["initial-file.txt"].name}||`) + }) + }) + + it("can bulk rename files", () => { + startNeovimWithYa().then((_dir) => { + // in yazi, bulk renaming is done by + // - selecting files and pressing "r". + // - It opens the editor with the names of the selected files. + // - Next, the editor must make changes to the file names and save the + // file. + // - Finally, yazi should rename the files to match the new names. + cy.typeIntoTerminal("{upArrow}") + cy.typeIntoTerminal("{control+a}r") + + // yazi should now have opened an embedded Neovim. The file name should say + // "bulk" somewhere to indicate this + cy.contains(new RegExp("yazi/bulk-\\d+")) + + // edit the name of the first file + cy.typeIntoTerminal("xxx") + cy.typeIntoTerminal(":xa{enter}") + + // yazi must now ask for confirmation + cy.contains("Continue to rename? (y/N):") + + // answer yes + cy.typeIntoTerminal("y{enter}") + }) + }) + + it("can open files with complex characters in their name", () => { + startNeovimWithYa().then((dir) => { + cy.typeIntoTerminal("{upArrow}") + + // enter the routes/ directory + cy.typeIntoTerminal("/routes{enter}") + cy.typeIntoTerminal("{rightArrow}") + cy.contains(dir.contents["routes/posts.$postId/route.tsx"].name) // file in the directory + + // enter routes/posts.$postId/ + cy.typeIntoTerminal("{rightArrow}") + + // select route.tsx + cy.typeIntoTerminal( + `/${dir.contents["routes/posts.$postId/route.tsx"].name}{enter}`, + ) + + // open the file + cy.typeIntoTerminal("{enter}") + + // close yazi just to be sure the file preview is not found instead + cy.get( + dir.contents["routes/posts.$postId/adjacent-file.tsx"].name, + ).should("not.exist") + + // the file contents should now be visible + cy.contains("02c67730-6b74-4b7c-af61-fe5844fdc3d7") + }) + }) +}) diff --git a/integration-tests/cypress/e2e/using-ya-to-read-events/reading-events.cy.ts b/integration-tests/cypress/e2e/using-ya-to-read-events/reading-events.cy.ts new file mode 100644 index 00000000..d69faf55 --- /dev/null +++ b/integration-tests/cypress/e2e/using-ya-to-read-events/reading-events.cy.ts @@ -0,0 +1,115 @@ +import { startNeovimWithYa } from "./startNeovimWithYa" + +describe("reading events", () => { + beforeEach(() => { + cy.visit("http://localhost:5173") + }) + + it("can read 'cd' events and use telescope in the latest directory", () => { + startNeovimWithYa() + // wait until text on the start screen is visible + cy.contains("If you see this text, Neovim is ready!") + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // move to the parent directory. This should make yazi send the "cd" event, + // indicating that the directory was changed + cy.contains("subdirectory") + cy.typeIntoTerminal("/subdirectory{enter}") + cy.typeIntoTerminal("{rightArrow}") + cy.typeIntoTerminal("{control+s}") + + // telescope should now be visible. Let's search for the contents of the + // file, which we know beforehand + cy.contains("Grep in") + cy.typeIntoTerminal("Hello") + + // we should see text indicating the search is limited to the current + // directory + cy.contains("Hello from the subdirectory! 👋") + }) + + it("can read 'trash' events and close an open buffer when its file was trashed", () => { + // NOTE: trash means moving a file to the trash, not deleting it permanently + + startNeovimWithYa().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file deletion + cy.typeIntoTerminal("d") + cy.contains("Move 1 selected file to trash?") + cy.typeIntoTerminal("y{enter}") + + cy.get("Move 1 selected file to trash").should("not.exist") + + // close yazi + cy.typeIntoTerminal("q") + + // internally, we should have received a trash event from yazi, and yazi.nvim should + // have closed the buffer + cy.contains(dir.contents["initial-file.txt"].name).should("not.exist") + cy.contains("If you see this text, Neovim is ready").should("not.exist") + }) + }) + + it("can read 'delete' events and close an open buffer when its file was deleted", () => { + // NOTE: delete means permanently deleting a file (not moving it to the trash) + + startNeovimWithYa().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file deletion + cy.typeIntoTerminal("D") + cy.contains("Delete 1 selected file permanently?") + cy.typeIntoTerminal("y{enter}") + + cy.get("Delete 1 selected file permanently").should("not.exist") + + // close yazi + cy.typeIntoTerminal("q") + + // internally, we should have received a delete event from yazi, and yazi.nvim should + // have closed the buffer + cy.get(dir.contents["initial-file.txt"].name).should("not.exist") + cy.contains("If you see this text, Neovim is ready").should("not.exist") + }) + }) + + it("can read 'rename' events and update the buffer name when the file was renamed", () => { + startNeovimWithYa().then((dir) => { + // the default file should already be open + cy.contains(dir.contents["initial-file.txt"].name) + cy.contains("If you see this text, Neovim is ready!") + + // start yazi + cy.typeIntoTerminal("{upArrow}") + + // start file renaming + cy.typeIntoTerminal("r") + cy.contains("Rename:") + cy.typeIntoTerminal("2{enter}") + + cy.get("Rename").should("not.exist") + + // yazi should be showing the new file name + const file = dir.contents["initial-file.txt"] + cy.contains(`${file.stem}2${file.extension}`) + + // close yazi + cy.typeIntoTerminal("q") + + // the buffer name should now be updated + cy.contains(`${file.stem}2${file.extension}`) + }) + }) +}) diff --git a/integration-tests/cypress/e2e/using-ya-to-read-events/startNeovimWithYa.ts b/integration-tests/cypress/e2e/using-ya-to-read-events/startNeovimWithYa.ts new file mode 100644 index 00000000..2166f558 --- /dev/null +++ b/integration-tests/cypress/e2e/using-ya-to-read-events/startNeovimWithYa.ts @@ -0,0 +1,15 @@ +import type { + StartNeovimArguments, + TestDirectory, +} from "../../../client/testEnvironmentTypes" + +export function startNeovimWithYa( + args?: Partial, +): Cypress.Chainable { + return cy.startNeovim({ + ...args, + startupScriptModifications: [ + "modify_yazi_config_to_use_ya_as_event_reader.lua", + ], + }) +} diff --git a/integration-tests/cypress/support/commands.ts b/integration-tests/cypress/support/commands.ts index 241c9d8f..a7372707 100644 --- a/integration-tests/cypress/support/commands.ts +++ b/integration-tests/cypress/support/commands.ts @@ -38,20 +38,19 @@ // } // } -import "../../client/startAppGlobalType" -import type { StartAppMessageArguments } from "../../client/startAppGlobalType" +import "../../client/testEnvironmentTypes" +import type { + StartNeovimArguments, + TestDirectory, +} from "../../client/testEnvironmentTypes" -export type StartNeovimArguments = { - filename?: string -} - -Cypress.Commands.add("startNeovim", (args?: StartNeovimArguments) => { +Cypress.Commands.add("startNeovim", (startArguments?: StartNeovimArguments) => { cy.window().then((win) => { - const startApp: StartAppMessageArguments = { - command: "nvim", - args: ["-u", "test-setup.lua", args?.filename ?? "initial-file.txt"], - } - win.startApp(startApp) + // eslint-disable-next-line @typescript-eslint/require-await + cy.task("createTempDir").then(async (dir) => { + void win.startNeovim(dir.rootPath, startArguments) + return dir + }) }) }) @@ -64,8 +63,17 @@ Cypress.Commands.add("typeIntoTerminal", (text: string) => { declare global { namespace Cypress { interface Chainable { - startNeovim(args?: StartNeovimArguments): Chainable + startNeovim(args?: StartNeovimArguments): Chainable typeIntoTerminal(text: string): Chainable + task(event: "createTempDir"): Chainable } } } + +afterEach(() => { + cy.task("showYaziLog") +}) + +beforeEach(() => { + cy.task("removeYaziLog") +}) diff --git a/integration-tests/cypress/support/e2e.ts b/integration-tests/cypress/support/e2e.ts index b6eca754..e2c2c2f5 100644 --- a/integration-tests/cypress/support/e2e.ts +++ b/integration-tests/cypress/support/e2e.ts @@ -15,6 +15,3 @@ // Import commands.js using ES2015 syntax: import "./commands" - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/integration-tests/package.json b/integration-tests/package.json index 74566273..58f69116 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -7,7 +7,7 @@ "dev": "concurrently --kill-others --names 'client,server,cypress' --prefix-colors 'blue,green,yellow' 'npm run dev:client' 'npm run dev:server' 'npm run cy:open'", "dev:client": "vite", "dev:server": "tsc-watch --noClear --build --onSuccess 'tsx ./server/server.ts'", - "cy:open": "cypress open", + "cy:open": "cypress open --e2e", "cy:run:ci": "concurrently --success command-cypress --kill-others --names 'client,server,cypress' --prefix-colors 'blue,green,yellow' 'npm run dev:client' 'npm run dev:server' 'wait-on --timeout 60000 http://127.0.0.1:5173 && npx cypress run'", "build": "tsc && vite build", "eslint": "eslint --max-warnings=0 .", diff --git a/integration-tests/server/TerminalApplication.ts b/integration-tests/server/TerminalApplication.ts index 8d85a32c..7bad2289 100644 --- a/integration-tests/server/TerminalApplication.ts +++ b/integration-tests/server/TerminalApplication.ts @@ -56,12 +56,13 @@ export class TerminalApplication { }): TerminalApplication { // NOTE the size for the terminal was chosen so that it looks good in the // cypress test preview + console.log(`Starting '${command} ${args.join(" ")}' in cwd '${cwd}'`) const ptyProcess = pty.spawn(command, args, { name: "xterm-color", cwd, env, - cols: 126, - rows: 44, + cols: 125, + rows: 43, }) const processId = ptyProcess.pid diff --git a/integration-tests/server/server.ts b/integration-tests/server/server.ts index fdf2ec78..13e30099 100644 --- a/integration-tests/server/server.ts +++ b/integration-tests/server/server.ts @@ -5,17 +5,19 @@ import express from "express" import assert from "node:assert" import { createServer } from "node:http" import path from "node:path" -import * as url from "url" -import type { StartAppMessageArguments } from "../client/startAppGlobalType" +import { fileURLToPath } from "url" +import type { StartNeovimServerArguments } from "../client/testEnvironmentTypes" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const testDirectory = path.join(__dirname, "..", "test-environment/") export type StdinMessage = "stdin" export type StdoutMessage = "stdout" -export type StartAppMessage = "startApp" +export type StartNeovimMessage = "startNeovim" const expressApp = express() const server = createServer(expressApp) const connections: Map = new Map() -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)) const io = new Server(server, { cors: { @@ -33,18 +35,41 @@ const io = new Server(server, { }) io.on("connection", function connection(socket) { - // TODO this should be generated by the browser to make it reliable - // or connection state recovery could be used - // https://socket.io/docs/v4/connection-state-recovery const peerId = socket.id socket.on( - "startApp" satisfies StartAppMessage, - function (args: StartAppMessageArguments) { - const testDirectory = path.join(__dirname, "..", "test-environment/") + "startNeovim" satisfies StartNeovimMessage, + function (startArgs: StartNeovimServerArguments) { + const args = ["-u", "test-setup.lua"] + if (startArgs.startupScriptModifications) { + for (const modification of startArgs.startupScriptModifications) { + switch (modification) { + // execute a lua script after startup, allowing the tests to modify + // the base config without overriding all of it + case "modify_yazi_config_to_use_ya_as_event_reader.lua": + const file = path.join( + testDirectory, + "config-modifications", + "modify_yazi_config_to_use_ya_as_event_reader.lua", + ) + args.push("-c", `lua dofile('${file}')`) + break + default: + modification satisfies never + throw new Error( + `unexpected startup script modification: ${String(modification)}`, + ) + } + } + } + if (startArgs.filename) { + const file = path.join(startArgs.directory, startArgs.filename) + args.push(file) + } + const app = TerminalApplication.start({ - command: args.command, - args: args.args, + command: "nvim", + args: args, cwd: testDirectory, env: process.env, @@ -60,7 +85,7 @@ io.on("connection", function connection(socket) { ) }) }, - } satisfies StartAppMessageArguments & Record) + }) connections.set(peerId, app) socket.on("disconnect", async (_reason) => { diff --git a/integration-tests/test-environment/config-modifications/modify_yazi_config_to_use_ya_as_event_reader.lua b/integration-tests/test-environment/config-modifications/modify_yazi_config_to_use_ya_as_event_reader.lua new file mode 100644 index 00000000..8ff77a47 --- /dev/null +++ b/integration-tests/test-environment/config-modifications/modify_yazi_config_to_use_ya_as_event_reader.lua @@ -0,0 +1,8 @@ +---@module "yazi" + +require('yazi').setup( + ---@type YaziConfig + { + use_ya_for_events_reading = true, + } +) diff --git a/integration-tests/test-environment/routes/posts.$postId/adjacent-file.tsx b/integration-tests/test-environment/routes/posts.$postId/adjacent-file.tsx new file mode 100644 index 00000000..e69de29b diff --git a/integration-tests/test-environment/routes/posts.$postId/route.tsx b/integration-tests/test-environment/routes/posts.$postId/route.tsx new file mode 100644 index 00000000..d5a15463 --- /dev/null +++ b/integration-tests/test-environment/routes/posts.$postId/route.tsx @@ -0,0 +1 @@ +// 02c67730-6b74-4b7c-af61-fe5844fdc3d7 diff --git a/integration-tests/test-environment/subdirectory/subdirectory-file.txt b/integration-tests/test-environment/subdirectory/subdirectory-file.txt new file mode 100644 index 00000000..a1ec338b --- /dev/null +++ b/integration-tests/test-environment/subdirectory/subdirectory-file.txt @@ -0,0 +1 @@ +Hello from the subdirectory! 👋 diff --git a/integration-tests/test-environment/test-setup.lua b/integration-tests/test-environment/test-setup.lua index 71643565..9f411e89 100644 --- a/integration-tests/test-environment/test-setup.lua +++ b/integration-tests/test-environment/test-setup.lua @@ -56,6 +56,20 @@ local plugins = { ---@type YaziConfig opts = { open_for_directories = true, + -- allows logging debug data, which can be shown in CI when cypress tests fail + log_level = vim.log.levels.DEBUG, + integrations = { + grep_in_directory = function(directory) + require('telescope.builtin').live_grep({ + -- disable previewer to be able to see the full directory name. The + -- tests can make assertions on this path. + previewer = false, + search = '', + prompt_title = 'Grep in ' .. directory, + cwd = directory, + }) + end, + }, }, }, { 'nvim-telescope/telescope.nvim', lazy = true }, diff --git a/lua/yazi.lua b/lua/yazi.lua index 58f82bae..5fc6ce7c 100644 --- a/lua/yazi.lua +++ b/lua/yazi.lua @@ -2,27 +2,35 @@ local window = require('yazi.window') local utils = require('yazi.utils') -local vimfn = require('yazi.vimfn') local configModule = require('yazi.config') local event_handling = require('yazi.event_handling') local Log = require('yazi.log') +local YaziProcess = require('yazi.yazi_process') local M = {} -M.yazi_loaded = false - ---@param config? YaziConfig? ---@param input_path? string ---@diagnostic disable-next-line: redefined-local function M.yazi(config, input_path) if utils.is_yazi_available() ~= true then - print('Please install yazi. Check documentation for more information') + print('Please install yazi. Check the documentation for more information') return end config = vim.tbl_deep_extend('force', configModule.default(), M.config, config or {}) + if + config.use_ya_for_events_reading == true + and utils.is_ya_available() ~= true + then + print( + 'Please install ya (the yazi command line utility). Check the documentation for more information' + ) + return + end + local path = utils.selected_file_path(input_path) local prev_win = vim.api.nvim_get_current_win() @@ -34,52 +42,56 @@ function M.yazi(config, input_path) local win = window.YaziFloatingWindow.new(config) win:open_and_display() - os.remove(config.chosen_file_path) - local cmd = string.format( - 'yazi %s --local-events "rename,delete,trash,move,cd" --chooser-file "%s" > "%s"', - vim.fn.shellescape(path.filename), - config.chosen_file_path, - config.events_file_path - ) + local yazi_process = YaziProcess:start( + config, + path, + function(exit_code, selected_files, events) + if exit_code ~= 0 then + print( + "yazi.nvim: had trouble opening yazi. Run ':checkhealth yazi' for more information." + ) + Log:debug( + string.format('yazi.nvim: had trouble opening yazi: %s', exit_code) + ) + return + end - if M.yazi_loaded == false then - Log:debug(string.format('Opening yazi with the command: (%s)', cmd)) - - local job_id = vimfn.termopen(cmd, { - ---@diagnostic disable-next-line: unused-local - on_exit = function(_job_id, code, _event) - M.yazi_loaded = false - if code ~= 0 then - print( - "yazi.nvim: had trouble opening yazi. Run ':checkhealth yazi' for more information." - ) - return + Log:debug( + string.format( + 'yazi process exited successfully with code: %s, selected_files %s, and events %s', + exit_code, + vim.inspect(selected_files), + vim.inspect(events) + ) + ) + + local event_info = event_handling.process_events_emitted_from_yazi(events) + + local last_directory = event_info.last_directory + if last_directory == nil then + if path:is_file() then + last_directory = path:parent() + else + last_directory = path end + end + utils.on_yazi_exited(prev_win, prev_buf, win, config, selected_files, { + last_directory = event_info.last_directory or path:parent(), + }) + end + ) - local events = utils.read_events_file(config.events_file_path) - local event_info = - event_handling.process_events_emitted_from_yazi(events) - - local last_directory = event_info.last_directory - if last_directory == nil then - if path:is_file() then - last_directory = path:parent() - else - last_directory = path - end - end - utils.on_yazi_exited(prev_win, prev_buf, win, config, { - last_directory = event_info.last_directory or path:parent(), - }) - end, - }) + config.hooks.yazi_opened(path.filename, win.content_buffer, config) + config.set_keymappings_function(win.content_buffer, config) - config.hooks.yazi_opened(path.filename, win.content_buffer, config) - config.set_keymappings_function(win.content_buffer, config) - win.on_resized = function(event) - vim.fn.jobresize(job_id, event.win_width, event.win_height) - end + win.on_resized = function(event) + vim.fn.jobresize( + yazi_process.yazi_job_id, + event.win_width, + event.win_height + ) end + vim.schedule(function() vim.cmd('startinsert') end) diff --git a/lua/yazi/config.lua b/lua/yazi/config.lua index 4fea7750..98f46a67 100644 --- a/lua/yazi/config.lua +++ b/lua/yazi/config.lua @@ -10,6 +10,8 @@ function M.default() return { log_level = vim.log.levels.OFF, open_for_directories = false, + -- NOTE: right now this is opt-in, but will be the default in the future + use_ya_for_events_reading = false, enable_mouse_support = false, open_file_function = openers.open_file, set_keymappings_function = M.default_set_keymappings_function, diff --git a/lua/yazi/event_handling.lua b/lua/yazi/event_handling.lua index 5f2694ad..52204d2c 100644 --- a/lua/yazi/event_handling.lua +++ b/lua/yazi/event_handling.lua @@ -70,8 +70,6 @@ end ---@param events YaziEvent[] ---@return {last_directory?: Path} function M.process_events_emitted_from_yazi(events) - -- process events emitted from yazi - ---@type Path | nil local last_directory = nil diff --git a/lua/yazi/health.lua b/lua/yazi/health.lua index b4ac105c..141b6fa9 100644 --- a/lua/yazi/health.lua +++ b/lua/yazi/health.lua @@ -34,19 +34,44 @@ return { return vim.health.warn( 'yazi version is too old, please upgrade to 0.2.5 or newer' ) - end - - local yazi_help = vim.fn.system('yazi --help') - if not yazi_help:find('--local-events', 1, true) then - vim.health.warn( - 'The yazi version does not support --local-events. Please upgrade to the newest version of yazi.' - ) + else + vim.health.info(('Found `yazi` version `%s` 👍'):format(semver)) end local logfile_location = require('yazi.log'):get_logfile_path() vim.health.info('yazi.nvim log file is at ' .. logfile_location) vim.health.info(' hint: use `gf` to open the file path under the cursor') + if require('yazi').config.use_ya_for_events_reading == true then + if vim.fn.executable('ya') ~= 1 then + vim.health.error( + 'You have opted in to using `ya` for events reading, but `ya` is not found on PATH. Please install `ya` or disable `use_ya_for_events_reading` in your config.' + ) + return + end + + -- example data: + -- Ya 0.2.5 (f5a7ace 2024-06-23) + local raw_ya_version = vim.fn.system('ya --version') or '' + local ya_semver = raw_ya_version:match('[Yy]a (%w+%.%w+%.%w+)') + if ya_semver == nil then + vim.health.warn( + string.format( + '`ya --version` looks unexpected, saw `%s` 🤔', + raw_ya_version + ) + ) + else + if not checker.gt(ya_semver, '0.2.4') then + vim.health.warn( + 'The `ya` executable version (yazi command line interface) is too old. Please upgrade to the newest version.' + ) + else + vim.health.info(('Found `ya` version `%s` 👍'):format(ya_semver)) + end + end + end + vim.health.ok('yazi') end, } diff --git a/lua/yazi/process/legacy_events_from_file.lua b/lua/yazi/process/legacy_events_from_file.lua new file mode 100644 index 00000000..7dcc3bc2 --- /dev/null +++ b/lua/yazi/process/legacy_events_from_file.lua @@ -0,0 +1,37 @@ +local utils = require('yazi.utils') + +-- The legacy way of reading events. Reads events from a file in one go after +-- the `yazi` process exits. +---@class (exact) LegacyEventReadingFromEventFile +---@field private config YaziConfig +local LegacyEventReadingFromEventFile = {} +---@diagnostic disable-next-line: inject-field +LegacyEventReadingFromEventFile.__index = LegacyEventReadingFromEventFile + +---@param config YaziConfig +function LegacyEventReadingFromEventFile:new(config) + self.config = config + return self +end + +---@param path Path +function LegacyEventReadingFromEventFile:get_yazi_command(path) + return string.format( + 'yazi %s --local-events "rename,delete,trash,move,cd" --chooser-file "%s" > "%s"', + vim.fn.shellescape(path.filename), + self.config.chosen_file_path, + self.config.events_file_path + ) +end + +function LegacyEventReadingFromEventFile:start() + return self +end + +function LegacyEventReadingFromEventFile:kill() end + +function LegacyEventReadingFromEventFile:wait() + return utils.read_events_file(self.config.events_file_path) +end + +return LegacyEventReadingFromEventFile diff --git a/lua/yazi/process/ya_process.lua b/lua/yazi/process/ya_process.lua new file mode 100644 index 00000000..34023fd1 --- /dev/null +++ b/lua/yazi/process/ya_process.lua @@ -0,0 +1,115 @@ +---@module "plenary.path" + +local Log = require('yazi.log') +local utils = require('yazi.utils') + +---@class (exact) YaProcess +---@field public events YaziEvent[] "The events that have been received from yazi" +---@field private config YaziConfig +---@field private ya_process vim.SystemObj +---@field private retries integer +local YaProcess = {} +---@diagnostic disable-next-line: inject-field +YaProcess.__index = YaProcess + +---@param config YaziConfig +function YaProcess:new(config) + self.config = config + self.events = {} + self.retries = 0 + + return self +end + +---@param path Path +function YaProcess:get_yazi_command(path) + return string.format( + 'yazi %s --chooser-file "%s"', + vim.fn.shellescape(path.filename), + self.config.chosen_file_path + ) +end + +function YaProcess:kill() + Log:debug('Killing ya process') + pcall(self.ya_process.kill, self.ya_process, 'sigterm') +end + +function YaProcess:wait(timeout) + Log:debug('Waiting for ya process to exit') + self.ya_process:wait(timeout) + return self.events +end + +function YaProcess:start() + local ya_command = { 'ya', 'sub', 'rename,delete,trash,move,cd' } + Log:debug( + string.format( + 'Opening ya with the command: (%s)', + table.concat(ya_command, ' ') + ) + ) + + self.ya_process = vim.system(ya_command, { + -- • text: (boolean) Handle stdout and stderr as text. + -- Replaces `\r\n` with `\n`. + text = true, + stderr = function(err, data) + if err then + Log:debug(string.format("ya stderr error: '%s'", data)) + end + + if data == nil then + -- weird event, ignore + return + end + + Log:debug(string.format("ya stderr: '%s'", data)) + + if data:find('No running Yazi instance found') then + if self.retries < 5 then + Log:debug( + 'Looks like starting ya failed because yazi had not started yet. Retrying to open ya...' + ) + self.retries = self.retries + 1 + vim.defer_fn(function() + self:start() + end, 50) + else + Log:debug('Failed to open ya after 5 retries') + end + end + end, + + stdout = function(err, data) + if err then + Log:debug(string.format("ya stdout error: '%s'", data)) + end + + if data == nil then + -- weird event, ignore + return + end + + -- remove the final newline character because it's annoying in the logs + if data:sub(-1) == '\n' then + data = data:sub(1, -2) + end + + Log:debug(string.format("ya stdout: '%s'", data)) + local parsed = utils.safe_parse_events({ data }) + for _, event in ipairs(parsed) do + self.events[#self.events + 1] = event + end + end, + + ---@param obj vim.SystemCompleted + on_exit = function(obj) + Log:debug(string.format('ya process exited with code: %s', obj.code)) + end, + }) + + return self +end + +return YaProcess diff --git a/lua/yazi/types.lua b/lua/yazi/types.lua index 3db45eee..8ad91067 100644 --- a/lua/yazi/types.lua +++ b/lua/yazi/types.lua @@ -6,6 +6,7 @@ ---@field public open_for_directories? boolean ---@field public chosen_file_path? string "the path to a temporary file that will be created by yazi to store the chosen file path" ---@field public events_file_path? string "the path to a temporary file that will be created by yazi to store events. A random path will be used by default" +---@field public use_ya_for_events_reading? boolean "use `ya`, the yazi command line application to read events from the yazi process. Right now this is opt-in, but will be the default in the future" ---@field public enable_mouse_support? boolean ---@field public open_file_function? fun(chosen_file: string, config: YaziConfig, state: YaziClosedState): nil "a function that will be called when a file is chosen in yazi" ---@field public set_keymappings_function? fun(buffer: integer, config: YaziConfig): nil "the function that will set the keymappings for the yazi floating window. It will be called after the floating window is created." diff --git a/lua/yazi/utils.lua b/lua/yazi/utils.lua index b20cf728..114326a6 100644 --- a/lua/yazi/utils.lua +++ b/lua/yazi/utils.lua @@ -4,11 +4,14 @@ local plenary_path = require('plenary.path') local M = {} ----@return boolean function M.is_yazi_available() return fn.executable('yazi') == 1 end +function M.is_ya_available() + return fn.executable('ya') == 1 +end + function M.file_exists(name) local f = io.open(name, 'r') if f ~= nil then @@ -37,9 +40,8 @@ end -- Returns parsed events from the yazi events file ---@param events_file_lines string[] ----@return YaziRenameEvent[] function M.parse_events(events_file_lines) - ---@type string[] + ---@type YaziEvent[] local events = {} for _, line in ipairs(events_file_lines) do @@ -60,7 +62,7 @@ function M.parse_events(events_file_lines) type = type, timestamp = timestamp, id = id, - data = vim.fn.json_decode(data_string), + data = vim.json.decode(data_string), } table.insert(events, event) elseif type == 'move' then @@ -75,7 +77,7 @@ function M.parse_events(events_file_lines) type = type, timestamp = timestamp, id = id, - data = vim.fn.json_decode(data_string), + data = vim.json.decode(data_string), } table.insert(events, event) elseif type == 'delete' then @@ -91,7 +93,7 @@ function M.parse_events(events_file_lines) type = type, timestamp = timestamp, id = id, - data = vim.fn.json_decode(data_string), + data = vim.json.decode(data_string), } table.insert(events, event) elseif type == 'trash' then @@ -107,7 +109,7 @@ function M.parse_events(events_file_lines) type = type, timestamp = timestamp, id = id, - data = vim.fn.json_decode(data_string), + data = vim.json.decode(data_string), } table.insert(events, event) elseif type == 'cd' then @@ -123,7 +125,7 @@ function M.parse_events(events_file_lines) type = type, timestamp = timestamp, id = id, - url = vim.fn.json_decode(data_string)['url'], + url = vim.json.decode(data_string)['url'], } table.insert(events, event) end @@ -133,7 +135,6 @@ function M.parse_events(events_file_lines) end ---@param path string ----@return YaziEvent[] function M.read_events_file(path) local success, events_file_lines = pcall(vim.fn.readfile, path) os.remove(path) @@ -141,9 +142,12 @@ function M.read_events_file(path) return {} end - -- selene: allow(shadowing) - ---@diagnostic disable-next-line: redefined-local - local success, events = pcall(M.parse_events, events_file_lines) + return M.safe_parse_events(events_file_lines) +end + +---@param event_lines string[] +function M.safe_parse_events(event_lines) + local success, events = pcall(M.parse_events, event_lines) if not success then return {} end @@ -198,8 +202,16 @@ end ---@param prev_buf integer ---@param window YaziFloatingWindow ---@param config YaziConfig +---@param selected_files string[] ---@param state YaziClosedState -function M.on_yazi_exited(prev_win, prev_buf, window, config, state) +function M.on_yazi_exited( + prev_win, + prev_buf, + window, + config, + selected_files, + state +) vim.cmd('silent! :checktime') -- open the file that was chosen @@ -214,13 +226,12 @@ function M.on_yazi_exited(prev_win, prev_buf, window, config, state) window:close() vim.api.nvim_set_current_win(prev_win) - if M.file_exists(config.chosen_file_path) == true then - local chosen_files = vim.fn.readfile(config.chosen_file_path) - - if #chosen_files > 1 then - config.hooks.yazi_opened_multiple_files(chosen_files, config, state) + -- if M.file_exists(config.chosen_file_path) == true then + if #selected_files > 0 then + if #selected_files > 1 then + config.hooks.yazi_opened_multiple_files(selected_files, config, state) else - local chosen_file = chosen_files[1] + local chosen_file = selected_files[1] config.hooks.yazi_closed_successfully(chosen_file, config, state) if chosen_file then config.open_file_function(chosen_file, config, state) diff --git a/lua/yazi/yazi_process.lua b/lua/yazi/yazi_process.lua new file mode 100644 index 00000000..628b9cf6 --- /dev/null +++ b/lua/yazi/yazi_process.lua @@ -0,0 +1,54 @@ +---@module "plenary.path" + +local YaProcess = require('yazi.process.ya_process') +local Log = require('yazi.log') +local utils = require('yazi.utils') +local LegacyEventReadingFromEventFile = + require('yazi.process.legacy_events_from_file') + +---@class YaziProcess +---@field private event_reader YaProcess | LegacyEventReadingFromEventFile "The process that reads events from yazi" +---@field public yazi_job_id integer +local YaziProcess = {} + +---@diagnostic disable-next-line: inject-field +YaziProcess.__index = YaziProcess + +---@param config YaziConfig +---@param path Path +---@param on_exit fun(code: integer, selected_files: string[], events: YaziEvent[]) +function YaziProcess:start(config, path, on_exit) + os.remove(config.chosen_file_path) + + Log:debug( + string.format( + 'use_ya_for_events_reading: %s', + config.use_ya_for_events_reading + ) + ) + self.event_reader = config.use_ya_for_events_reading == true + and YaProcess:new(config) + or LegacyEventReadingFromEventFile:new(config) + + local yazi_cmd = self.event_reader:get_yazi_command(path) + + Log:debug(string.format('Opening yazi with the command: (%s)', yazi_cmd)) + self.yazi_job_id = vim.fn.termopen(yazi_cmd, { + on_exit = function(_, code) + self.event_reader:kill() + local events = self.event_reader:wait(1000) + + local chosen_files = {} + if utils.file_exists(config.chosen_file_path) == true then + chosen_files = vim.fn.readfile(config.chosen_file_path) + end + on_exit(code, chosen_files, events) + end, + }) + + self.event_reader:start() + + return self +end + +return YaziProcess diff --git a/spec/yazi/health_spec.lua b/spec/yazi/health_spec.lua new file mode 100644 index 00000000..8d7ce5ba --- /dev/null +++ b/spec/yazi/health_spec.lua @@ -0,0 +1,130 @@ +local stub = require('luassert.stub') +local assert = require('luassert') +local yazi = require('yazi') + +local function assert_buffer_contains_text(needle) + local buffer_text = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local text = table.concat(buffer_text, '\n') + local message = string.format( + "Expected the main string to contain the substring.\nMain string: '%s'\nSubstring: '%s'", + text, + needle + ) + + local found = string.find(text, needle, 1, true) ~= nil + assert(found, message) +end + +-- make nvim find the health check file so that it can be executed by :checkhealth +-- without this, the health check will not be found +vim.opt.rtp:append('.') + +local mock_app_versions = {} + +describe('the happy path', function() + local snapshot + + before_each(function() + snapshot = assert:snapshot() + mock_app_versions = { + ['yazi'] = 'yazi 0.2.5 (f5a7ace 2024-06-23)', + ['ya'] = 'Ya 0.2.5 (f5a7ace 2024-06-23)', + ['nvim-0.10.0'] = true, + } + + stub(vim.fn, 'has', function(needle) + if mock_app_versions[needle] then + return 1 + else + return 0 + end + end) + + stub(vim.fn, 'executable', function(command) + return mock_app_versions[command] and 1 or 0 + end) + + stub(vim.fn, 'system', function(command) + if command == 'yazi --version' then + return mock_app_versions['yazi'] + elseif command == 'ya --version' then + return mock_app_versions['ya'] + else + error('unexpected command: ' .. command) + end + end) + end) + + after_each(function() + snapshot:revert() + end) + + it('reports everything is ok', function() + yazi.setup({ use_ya_for_events_reading = true }) + vim.cmd('checkhealth yazi') + + assert_buffer_contains_text('Found `yazi` version `0.2.5`') + assert_buffer_contains_text('Found `ya` version `0.2.5`') + assert_buffer_contains_text('OK yazi') + end) + + it('warns if the yazi version is too old', function() + mock_app_versions['yazi'] = 'yazi 0.2.4 (f5a7ace 2024-06-23)' + vim.cmd('checkhealth yazi') + + assert_buffer_contains_text( + 'yazi version is too old, please upgrade to 0.2.5 or newer' + ) + end) + + it('warns if the ya version is too old', function() + yazi.setup({ use_ya_for_events_reading = true }) + mock_app_versions['ya'] = 'Ya 0.2.4 (f5a7ace 2024-06-23)' + + vim.cmd('checkhealth yazi') + + assert_buffer_contains_text( + 'WARNING The `ya` executable version (yazi command line interface) is too old.' + ) + end) + + it('warns when yazi is not found', function() + mock_app_versions['yazi'] = 'command not found' + end) + + it('warns when ya is not found', function() + mock_app_versions['ya'] = 'command not found' + + vim.cmd('checkhealth yazi') + + assert_buffer_contains_text( + 'WARNING `ya --version` looks unexpected, saw `command not found`' + ) + end) + + it( + 'warns when `ya` cannot be found but is set as the event_reader', + function() + stub(vim.fn, 'executable', function(command) + if command == 'ya' then + return 0 + else + return 1 + end + end) + + require('yazi').setup( + ---@type YaziConfig + { + use_ya_for_events_reading = true, + } + ) + + vim.cmd('checkhealth yazi') + + assert_buffer_contains_text( + 'ERROR You have opted in to using `ya` for events reading, but `ya` is not found on PATH.' + ) + end + ) +end) diff --git a/spec/yazi/helpers/fake_yazi_process.lua b/spec/yazi/helpers/fake_yazi_process.lua new file mode 100644 index 00000000..5f51d252 --- /dev/null +++ b/spec/yazi/helpers/fake_yazi_process.lua @@ -0,0 +1,26 @@ +---@module "plenary.path" + +local M = {} + +---@class FakeYaziArguments +---@field code integer +---@field selected_files string[] +---@field events YaziEvent[] +M.mocks = {} + +---@param arguments { code?: integer, selected_files?: string[], events?: YaziEvent[]} +function M.setup_created_instances_to_instantly_exit(arguments) + M.mocks.code = arguments.code or 0 + M.mocks.selected_files = arguments.selected_files or {} + M.mocks.events = arguments.events or {} +end + +-- Fake yazi process that instantly exits with the mocked data that was set up +-- before. +---@param on_exit fun(code: integer, selected_files: string[], events: YaziEvent[]) +---@diagnostic disable-next-line: unused-local +function M:start(_, _, on_exit) + on_exit(M.mocks.code, M.mocks.selected_files, M.mocks.events) +end + +return M diff --git a/spec/yazi/open_dir_spec.lua b/spec/yazi/open_dir_spec.lua deleted file mode 100644 index a7283099..00000000 --- a/spec/yazi/open_dir_spec.lua +++ /dev/null @@ -1,32 +0,0 @@ -local assert = require('luassert') -local mock = require('luassert.mock') -local match = require('luassert.match') - -local api_mock = mock(require('yazi.vimfn')) - -local plugin = require('yazi') - -describe('when the user set open_for_directories = true', function() - before_each(function() - ---@diagnostic disable-next-line: missing-fields - plugin.setup({ - open_for_directories = true, - chosen_file_path = '/tmp/yazi_filechosen', - events_file_path = '/tmp/yazi.nvim.events.txt', - }) - end) - - after_each(function() - mock.clear(api_mock) - end) - - it('shows yazi when a directory is opened', function() - -- instead of netrw opening, yazi should open - vim.api.nvim_command('edit /') - - assert.stub(api_mock.termopen).was_called_with( - 'yazi \'/\' --local-events "rename,delete,trash,move,cd" --chooser-file "/tmp/yazi_filechosen" > "/tmp/yazi.nvim.events.txt"', - match.is_table() - ) - end) -end) diff --git a/spec/yazi/plugin_spec.lua b/spec/yazi/plugin_spec.lua index 39976e66..e72f985e 100644 --- a/spec/yazi/plugin_spec.lua +++ b/spec/yazi/plugin_spec.lua @@ -1,10 +1,12 @@ local assert = require('luassert') local plugin = require('yazi.plugin') +local stub = require('luassert.stub') describe('installing a plugin', function() local base_dir = os.tmpname() -- create a temporary file with a unique name before_each(function() + stub(vim, 'notify') -- convert the unique name from a file to a directory assert(base_dir:match('/tmp/'), 'Failed to create a temporary directory') os.remove(base_dir) @@ -12,6 +14,7 @@ describe('installing a plugin', function() end) after_each(function() + stub(vim, 'notify'):revert() vim.fn.delete(base_dir, 'rf') end) diff --git a/spec/yazi/yazi_spec.lua b/spec/yazi/yazi_spec.lua index eab8240f..5bc2db66 100644 --- a/spec/yazi/yazi_spec.lua +++ b/spec/yazi/yazi_spec.lua @@ -1,40 +1,42 @@ +---@module "plenary.path" + local assert = require('luassert') local mock = require('luassert.mock') local match = require('luassert.match') local spy = require('luassert.spy') - -local api_mock = mock(require('yazi.vimfn')) +package.loaded['yazi.yazi_process'] = + require('spec.yazi.helpers.fake_yazi_process') +local fake_yazi_process = require('spec.yazi.helpers.fake_yazi_process') +local yazi_process = require('yazi.yazi_process') local plugin = require('yazi') describe('opening a file', function() after_each(function() - mock.clear(api_mock) + package.loaded['yazi.yazi_process'] = yazi_process end) - local termopen = spy.on(api_mock, 'termopen') - before_each(function() - mock.clear(termopen) - plugin.setup({}) + mock.revert(fake_yazi_process) + package.loaded['yazi.yazi_process'] = mock(fake_yazi_process) + plugin.setup({ + -- set_keymappings_function can only work with a real yazi process + set_keymappings_function = function() end, + }) end) - ---@param target_file string - local function setup_fake_yazi_opens_file(target_file) - -- have to start editing a valid file, otherwise the plugin will ignore the callback - vim.cmd('edit /abc/a.txt') - - termopen.callback = function(_, callback) - -- simulate yazi writing to the output file. This is done when a file is - -- chosen in yazi - local exit_code = 0 - vim.fn.writefile({ target_file }, '/tmp/yazi_filechosen') - callback.on_exit('job-id-ignored', exit_code, 'event-ignored') - return 0 - end + ---@param file string + local function assert_opened_yazi_with_file(file) + local call = mock(fake_yazi_process).start.calls[1] + + ---@type Path + local path = call.vals[3] + assert.equals(file, path.filename) end it('opens yazi with the current file selected', function() + fake_yazi_process.setup_created_instances_to_instantly_exit({}) + -- the file name should have a space as well as special characters, in order to test that vim.api.nvim_command('edit ' .. vim.fn.fnameescape('/abc/test file-$1.txt')) plugin.yazi({ @@ -42,13 +44,12 @@ describe('opening a file', function() events_file_path = '/tmp/yazi.nvim.events.txt', }) - assert.stub(api_mock.termopen).was_called_with( - 'yazi \'/abc/test file-$1.txt\' --local-events "rename,delete,trash,move,cd" --chooser-file "/tmp/yazi_filechosen" > "/tmp/yazi.nvim.events.txt"', - match.is_table() - ) + assert_opened_yazi_with_file('/abc/test file-$1.txt') end) it('opens yazi with the current directory selected', function() + fake_yazi_process.setup_created_instances_to_instantly_exit({}) + vim.api.nvim_command('edit /tmp/') plugin.yazi({ @@ -56,35 +57,22 @@ describe('opening a file', function() events_file_path = '/tmp/yazi.nvim.events.txt', }) - assert.stub(api_mock.termopen).was_called_with( - 'yazi \'/tmp/\' --local-events "rename,delete,trash,move,cd" --chooser-file "/tmp/yazi_filechosen" > "/tmp/yazi.nvim.events.txt"', - match.is_table() - ) - end) - - describe("when a file is selected in yazi's chooser", function() - it('can open files with complex characters in their name', function() - -- the filename contains a '$' character which can be problematic to nvim - local target_file = 'routes/posts.$postId/route.tsx' - setup_fake_yazi_opens_file(target_file) - - plugin.yazi({ - chosen_file_path = '/tmp/yazi_filechosen', - set_keymappings_function = function() end, - }) - - assert.equals(target_file, vim.fn.expand('%')) - end) + assert_opened_yazi_with_file('/tmp/') end) it( "calls the yazi_closed_successfully hook when a file is selected in yazi's chooser", function() local target_file = '/abc/test-file-potato.txt' - setup_fake_yazi_opens_file(target_file) + + fake_yazi_process.setup_created_instances_to_instantly_exit({ + selected_files = { target_file }, + }) + ---@param state YaziClosedState + ---@diagnostic disable-next-line: unused-local local spy_hook = spy.new(function(chosen_file, _config, state) - assert.equals('/abc/test-file-potato.txt', chosen_file) + assert.equals(target_file, chosen_file) assert.equals('/abc', state.last_directory.filename) end) @@ -92,7 +80,6 @@ describe('opening a file', function() plugin.yazi({ chosen_file_path = '/tmp/yazi_filechosen', - set_keymappings_function = function() end, ---@diagnostic disable-next-line: missing-fields hooks = { ---@diagnostic disable-next-line: assign-type-mismatch @@ -102,7 +89,7 @@ describe('opening a file', function() assert .spy(spy_hook) - .was_called_with('/abc/test-file-potato.txt', match.is_table(), match.is_table()) + .was_called_with(target_file, match.is_table(), match.is_table()) end ) @@ -112,7 +99,6 @@ describe('opening a file', function() vim.api.nvim_command('edit /abc/yazi_opened_hook_file.txt') plugin.yazi({ - set_keymappings_function = function() end, ---@diagnostic disable-next-line: missing-fields hooks = { ---@diagnostic disable-next-line: assign-type-mismatch @@ -127,14 +113,15 @@ describe('opening a file', function() it('calls the open_file_function to open the selected file', function() local target_file = '/abc/test-file-lnotial.txt' - setup_fake_yazi_opens_file(target_file) + fake_yazi_process.setup_created_instances_to_instantly_exit({ + selected_files = { target_file }, + }) local spy_open_file_function = spy.new() vim.api.nvim_command('edit ' .. target_file) plugin.yazi({ chosen_file_path = '/tmp/yazi_filechosen', - set_keymappings_function = function() end, ---@diagnostic disable-next-line: assign-type-mismatch open_file_function = spy_open_file_function, }) @@ -149,24 +136,13 @@ describe('opening multiple files', function() local target_file_1 = '/abc/test-file-multiple-1.txt' local target_file_2 = '/abc/test-file-multiple-2.txt' - before_each(function() - local termopen = spy.on(api_mock, 'termopen') - termopen.callback = function(_, callback) - -- simulate yazi writing to the output file. This is done when a file is - -- chosen in yazi - local exit_code = 0 - vim.fn.writefile({ - target_file_1, - target_file_2, - }, '/tmp/yazi_filechosen-123') - callback.on_exit('job-id-ignored', exit_code, 'event-ignored') - end - end) - it('can open multiple files', function() + fake_yazi_process.setup_created_instances_to_instantly_exit({ + selected_files = { target_file_1, target_file_2 }, + }) + local spy_open_multiple_files = spy.new() plugin.yazi({ - set_keymappings_function = function() end, ---@diagnostic disable-next-line: missing-fields hooks = { ---@diagnostic disable-next-line: assign-type-mismatch