From 40bc9d49c2d9c6b98b68e6a37565743f0e9dfd6f Mon Sep 17 00:00:00 2001 From: MasouShizuka Date: Thu, 1 Aug 2024 18:53:24 +0800 Subject: [PATCH] feat: migrate to yazi v0.3, add persistence method based on lua api --- README.md | 23 +++ init.lua | 541 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 510 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 3d6255a..905ba70 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ https://github.com/MasouShizuka/projects.yazi/assets/44764707/79c3559a-7776-48cd ## Installation +```sh +ya pack -a MasouShizuka/projects.yazi +``` + +or + ```sh # Windows git clone https://github.com/MasouShizuka/projects.yazi.git %AppData%\yazi\config\plugins\projects.yazi @@ -82,6 +88,10 @@ The following are the default configurations: ```lua require("projects"):setup({ + save = { + method = "yazi", -- yazi | lua + lua_save_path = "", -- windows: "%APPDATA%/yazi/state/projects.json", unix: "~/.config/yazi/state/projects.json" + }, last = { update_after_save = true, update_after_load = true, @@ -98,6 +108,19 @@ require("projects"):setup({ }) ``` +### `save` + +> [!NOTE] +> Yazi's api sometimes doesn't work on Windows, which is why the `lua` method is proposed + +`method` means the method of saving projects: +- `yazi`: using `yazi` api to save to `.dds` file +- `lua`: using Lua to save + +`lua_save_path` means the path of saved file with lua api, the defalut is +- `Windows`: `%APPDATA%/yazi/state/projects.json` +- `Unix`: `~/.config/yazi/state/projects.json` + ### `last` The last project is loaded by `load_last` command. diff --git a/init.lua b/init.lua index 7005804..cda327c 100644 --- a/init.lua +++ b/init.lua @@ -1,3 +1,389 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + ["\\"] = "\\", + ["\""] = "\"", + ["\b"] = "b", + ["\f"] = "f", + ["\n"] = "n", + ["\r"] = "r", + ["\t"] = "t", +} + +local escape_char_map_inv = { ["/"] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + ["nil"] = encode_nil, + ["table"] = encode_table, + ["string"] = encode_string, + ["number"] = encode_number, + ["boolean"] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return (encode(val)) +end + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[select(i, ...)] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + ["true"] = true, + ["false"] = false, + ["null"] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error(string.format("%s at line %d col %d", msg, line_count, col_count)) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error(string.format("invalid unicode codepoint '%x'", n)) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber(s:sub(1, 4), 16) + local n2 = tonumber(s:sub(7, 10), 16) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + ['"'] = parse_string, + ["0"] = parse_number, + ["1"] = parse_number, + ["2"] = parse_number, + ["3"] = parse_number, + ["4"] = parse_number, + ["5"] = parse_number, + ["6"] = parse_number, + ["7"] = parse_number, + ["8"] = parse_number, + ["9"] = parse_number, + ["-"] = parse_number, + ["t"] = parse_literal, + ["f"] = parse_literal, + ["n"] = parse_literal, + ["["] = parse_array, + ["{"] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + +------------------------------------------------------------------------------- +-- json.lua +------------------------------------------------------------------------------- + local SUPPORTED_KEYS_MAP = { ["0"] = 1, ["1"] = 2, @@ -128,6 +514,75 @@ local SUPPORTED_KEYS = { { on = "z" }, } +local _load_config = ya.sync(function(state, args) + state.save = { + method = "yazi", + lua_save_path = "", + } + if type(args.save) == "table" then + if type(args.save.method) == "string" then + state.save.method = args.save.method + end + if type(args.save.lua_save_path) == "string" then + state.save.lua_save_path = args.save.lua_save_path + else + local lua_save_path + local appdata = os.getenv("APPDATA") + local postfix = "/yazi/state/projects.json" + if appdata then + lua_save_path = appdata:gsub("\\", "/") .. postfix + else + lua_save_path = os.getenv("HOME") .. postfix + end + + state.save.lua_save_path = lua_save_path + end + end + + state.last = { + update_after_save = true, + update_after_load = true, + } + if type(args.last) == "table" then + if type(args.last.update_after_save) == "boolean" then + state.last.update_after_save = args.last.update_after_save + end + if type(args.last.update_after_load) == "boolean" then + state.last.update_after_load = args.last.update_after_load + end + end + + state.merge = { + quit_after_merge = false, + } + if type(args.merge) == "table" then + if type(args.merge.quit_after_merge) == "boolean" then + state.merge.quit_after_merge = args.merge.quit_after_merge + end + end + + state.notify = { + enable = true, + title = "Projects", + timeout = 3, + level = "info", + } + if type(args.notify) == "table" then + if type(args.notify.enable) == "boolean" then + state.notify.enable = args.notify.enable + end + if type(args.notify.title) == "string" then + state.notify.title = args.notify.title + end + if type(args.notify.timeout) == "number" then + state.notify.timeout = args.notify.timeout + end + if type(args.notify.level) == "string" then + state.notify.level = args.notify.level + end + end +end) + local _notify = ya.sync(function(state, message) ya.notify({ title = state.notify.title, @@ -146,21 +601,41 @@ end) local _save_projects = ya.sync(function(state, projects) state.projects = projects - ps.pub_static(10, "projects", projects) + + if state.save.method == "yazi" then + ps.pub_to(0, "@projects", projects) + elseif state.save.method == "lua" then + local f = io.open(state.save.lua_save_path, "w") + if not f then + return + end + f:write(json.encode(projects)) + io.close(f) + end end) local _load_projects = ya.sync(function(state) - ps.sub_remote("projects", function(body) - if not state.projects and body then - state.projects = _get_default_projects() + if state.save.method == "yazi" then + ps.sub_remote("@projects", function(body) + if not state.projects and body then + state.projects = _get_default_projects() - for _, value in pairs(body.list) do - state.projects.list[#state.projects.list + 1] = value + for _, value in pairs(body.list) do + state.projects.list[#state.projects.list + 1] = value + end + + state.projects.last = body.last end - - state.projects.last = body.last + end) + elseif state.save.method == "lua" then + local f = io.open(state.save.lua_save_path, "r") + if not f then + return end - end) + local projects = json.decode(f:read("*a")) or _get_default_projects() + io.close(f) + state.projects = projects + end end) local _get_projects = ya.sync(function(state) @@ -189,7 +664,7 @@ local _get_current_project = ya.sync(function(state) for index, tab in ipairs(tabs) do project.tabs[#project.tabs + 1] = { idx = index, - cwd = tostring(tab.current.cwd), + cwd = tostring(tab.current.cwd):gsub("\\", "/"), } end @@ -427,50 +902,8 @@ return { delete_project(selected_idx) end end, - setup = function(state, args) - state.last = { - update_after_save = true, - update_after_load = true, - } - if type(args.last) == "table" then - if type(args.last.update_after_save) == "boolean" then - state.last.update_after_save = args.last.update_after_save - end - if type(args.last.update_after_load) == "boolean" then - state.last.update_after_load = args.last.update_after_load - end - end - - state.merge = { - quit_after_merge = false, - } - if type(args.merge) == "table" then - if type(args.merge.quit_after_merge) == "boolean" then - state.merge.quit_after_merge = args.merge.quit_after_merge - end - end - - state.notify = { - enable = true, - title = "Projects", - timeout = 3, - level = "info", - } - if type(args.notify) == "table" then - if type(args.notify.enable) == "boolean" then - state.notify.enable = args.notify.enable - end - if type(args.notify.title) == "string" then - state.notify.title = args.notify.title - end - if type(args.notify.timeout) == "number" then - state.notify.timeout = args.notify.timeout - end - if type(args.notify.level) == "string" then - state.notify.level = args.notify.level - end - end - + setup = function(_, args) + _load_config(args) _load_projects() _merge_event() end,