From 912ca601aba44f41aedb12754a3a9157f64d4bf9 Mon Sep 17 00:00:00 2001 From: "Kartik K. Agaram" Date: Sun, 16 Apr 2023 11:15:03 -0700 Subject: [PATCH] new file-system format for freewheeling apps 1. No more version history, now we have just the contents of the current version. 2. Editing a definition no longer changes the order in which definitions load. This should make repos easier to browse, and more amenable to modify. You don't need driver.love anymore. And a stable order eliminates some gotchas. For example: using driver.love, define `Foo = 3` in a definition define `Bar = Foo + 1` edit and redefine `Foo = 4` Before this commit, you'd get an error when you restart the app. Definitions used to be loaded in version order, and editing a definition would move it to the end of the load order, potentially after definitions using it. I mostly avoided this by keeping top-level definitions independent. It's fine to refer to any definition inside a function body, we only need to be careful with initializers for global variables which run immediately while loading. After this commit you can still end up in a weird state if you modify a definition that other later definitions use. In the above example, you will now see Foo = 4 and Bar = 4. But when you restart, Foo = 4 and Bar = 5. But that's no more confusing than Emacs's C-x C-e. It's still a good idea to keep top-level definitions order-independent. It's just confusing in a similar way to existing tools if you fail to do so. And your tools won't tend to break as badly. Why did I ever do my weird version history thing? I think it's my deep aversion to risking losing any data entered. (Even though the app currently will seem to lose data in those situations. You'd need to leave your tools to find the data.) Now I rely on driver.love's undo to avoid data loss, but once you shut it down you're stuck with what you have on disk. Or in git. I also wasn't aware for a long time of any primitives for deleting files. This might have colored my choices a lot. --- 0000-freewheeling-start | 2 + 0001-fwmanifest | 1 - README.md | 8 +- head | 1 - live.lua | 196 ++++++++++++---------------------------- representation.md | 74 --------------- tools/stitch-live.lua | 37 -------- 7 files changed, 66 insertions(+), 253 deletions(-) create mode 100644 0000-freewheeling-start delete mode 100644 0001-fwmanifest delete mode 100644 head delete mode 100644 representation.md delete mode 100644 tools/stitch-live.lua diff --git a/0000-freewheeling-start b/0000-freewheeling-start new file mode 100644 index 0000000..0761829 --- /dev/null +++ b/0000-freewheeling-start @@ -0,0 +1,2 @@ +This file contains no definition, but is used as a marker in the save dir to +indicate all definitions have been copied from the repo to the save dir. diff --git a/0001-fwmanifest b/0001-fwmanifest deleted file mode 100644 index 23a5f80..0000000 --- a/0001-fwmanifest +++ /dev/null @@ -1 +0,0 @@ -{"fw_parent":0,"on":1} diff --git a/README.md b/README.md index f92ae4c..bf46b82 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ This repo is a template you can copy to create your own live apps that juggle text editor widgets. The editors support copy/paste, search, infinite undo, etc. You can't modify editor functionality live (yet?). -[More information about the on-disk representation of freewheeling apps.](representation.md) - This repo is a fork of [lines.love](http://akkartik.name/lines.html), an editor for plain text where you can also seamlessly insert line drawings. Designed above all to be easy to modify and give you early warning if your @@ -21,12 +19,18 @@ extremely well-behaved. I'll assume below that you can invoke it using the Run this app from the terminal, [passing its directory to LÖVE](https://love2d.org/wiki/Getting_Started#Running_Games) +## Hacking + To modify it live without restarting the app each time, download [the driver app](https://git.sr.ht/~akkartik/driver.love). Here's an example session using a fork of this repo: ![making changes without restarting the app](assets/2.gif) +To publish your changes: + * delete all files with a numeric prefix from the repo, and then + * move all files with a numeric prefix from the save dir to the repo + ## Keyboard shortcuts Up to you! But within the included editor widget if you use it: diff --git a/head b/head deleted file mode 100644 index d00491f..0000000 --- a/head +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/live.lua b/live.lua index 29124a1..fd856f3 100644 --- a/live.lua +++ b/live.lua @@ -1,18 +1,17 @@ -- A general architecture for free-wheeling, live programs: -- on startup: -- scan both the app directory and the save directory for files with numeric prefixes --- from the numeric prefix in file 'head', obtain a manifest --- load all files (which must start with a numeric prefix) from the manifest +-- load files in order -- -- then start drawing frames on screen and reacting to events -- -- events from keyboard and mouse are handled as the app desires -- --- on incoming messages to a specific file, however, the app must: --- save the message's value to a new, smallest unused numeric prefix --- execute the value --- if there's an error, go back to the previous value of the same --- definition if one exists +-- on incoming messages to a specific file, the app must: +-- determine the definition name from the first word +-- execute the value, returning any errors +-- look up the filename for the definition or define a new filename for it +-- save the message's value to the filename -- -- if a game encounters a run-time error, send it to the driver and await -- further instructions. The app will go unresponsive in the meantime, that @@ -27,17 +26,18 @@ Live = {} -- these will be modified live on = {} --- === on startup, load the version at head +-- === on startup, load all files with numeric prefix function live.initialize(arg) live.freeze_all_existing_definitions() -- version control - Live.head = 0 - Live.next_version = 1 - Live.history = {} -- array of filename roots corresponding to each numeric prefix - Live.manifest = {} -- mapping from roots to numeric prefixes as of version Live.head + Live.filenames_to_load = {} -- filenames in order of numeric prefix + Live.filename = {} -- map from definition name to filename (including numeric prefix) + Live.final_prefix = 0 live.load_files_so_far() + + -- some hysteresis Live.previous_read = 0 if on.load then on.load() end @@ -45,114 +45,48 @@ end function live.load_files_so_far() print('new edits will go to ' .. love.filesystem.getSaveDirectory()) - local files = {} - live.append_files_with_numeric_prefix('', files) - table.sort(files) - live.check_integrity(files) - live.append_files_with_numeric_prefix(love.filesystem.getSaveDirectory(), files) - table.sort(files) - live.check_integrity(files) - Live.history = live.load_history(files) - Live.next_version = #Live.history + 1 - local head_string = love.filesystem.read('head') - Live.head = tonumber(head_string) - if Live.head > 0 then - Live.manifest = json.decode(love.filesystem.read(live.versioned_manifest(Live.head))) - end - live.load_everything_in_manifest() -end - -function live.append_files_with_numeric_prefix(dir, files) - for _,file in ipairs(love.filesystem.getDirectoryItems(dir)) do - if file:match('^%d') then - table.insert(files, file) - end - end -end - -function live.check_integrity(files) - local manifest_found, file_found = false, false - local expected_index = 1 - for _,file in ipairs(files) do - for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do - -- only runs once - local index = tonumber(numeric_prefix) - -- skip files without numeric prefixes - if index ~= nil then - if index < expected_index then - print(index, expected_index) - end - assert(index >= expected_index) - if index > expected_index then - assert(index == expected_index+1) - assert(manifest_found and file_found) - expected_index = index - manifest_found, file_found = false, false - end - assert(index == expected_index) - if root == 'fwmanifest' then - assert(not manifest_found) - manifest_found = true - else - assert(not file_found) - file_found = true - end + -- if necessary, copy files from repo to save dir + if io.open(love.filesystem.getSaveDirectory()..'/0000-freewheeling-start') == nil then + print('copying all definitions from repo to save dir') + for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do + for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do + -- only runs once + local buf = love.filesystem.read(filename) + print('copying', filename) + love.filesystem.write(filename, buf) end end end -end - -function live.load_history(files) - local result = {} - for _,file in ipairs(files) do - for numeric_prefix, root in file:gmatch('(%d+)-(.+)') do + -- load files from save dir + for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do + for numeric_prefix, root in filename:gmatch('(%d+)-(.+)') do -- only runs once - local index = tonumber(numeric_prefix) - -- skip - if index ~= nil then - if root ~= 'fwmanifest' then - assert(index == #result+1) - table.insert(result, root) - end + if tonumber(numeric_prefix) > 0 then -- skip 0000 + Live.filename[root] = filename + table.insert(Live.filenames_to_load, filename) + Live.final_prefix = math.max(Live.final_prefix, tonumber(numeric_prefix)) end end end - return result + table.sort(Live.filenames_to_load) + live.load_all(files) end -function live.load_everything_in_manifest() - local files_to_load = {} - for k,v in pairs(Live.manifest) do - -- Most keys in the manifest are definitions. If we need to store any - -- metadata we'll do it in keys starting with a specific prefix. - if not starts_with(k, 'fw_') then - local root, index = k, v - local filename = live.versioned_filename(index, root) - table.insert(files_to_load, filename) - end - end - table.sort(files_to_load) - for _,filename in ipairs(files_to_load) do +function live.load_all() + for _,filename in ipairs(Live.filenames_to_load) do +--? print('loading', filename) local buf = love.filesystem.read(filename) assert(buf and buf ~= '') local status, err = live.eval(buf) - if status == nil then - error(('error loading %s from manifest: %s'):format(filename, err)) + if not status then + return err end end end -PARENT = 'fw_parent' + APP = 'fw_app' -function live.versioned_filename(index, root) - return ('%04d-%s'):format(index, root) -end - -function live.versioned_manifest(index) - return ('%04d-fwmanifest'):format(index) -end - -- === on each frame, check for messages and alter the app as needed function live.update(dt) @@ -221,18 +155,19 @@ function live.run(buf) elseif cmd == 'RESTART' then restart() elseif cmd == 'MANIFEST' then - Live.manifest[APP] = love.filesystem.getIdentity() -- doesn't need to be persisted, but no harm if it does.. - live.send_to_driver(json.encode(Live.manifest)) + Live.filename[APP] = love.filesystem.getIdentity() + live.send_to_driver(json.encode(Live.filename)) elseif cmd == 'DELETE' then - local definition_name = buf:match('^%S+%s+(%S+)') - Live.manifest[definition_name] = nil - live.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil` - local next_filename = live.versioned_filename(Live.next_version, definition_name) - love.filesystem.write(next_filename, '') - table.insert(Live.history, definition_name) - live.roll_forward() + local definition_name = buf:match('^%s*%S+%s+(%S+)') + if Live.filename[definition_name] then + local index = table.find(Live.filenames_to_load, Live.filename[definition_name]) + table.remove(Live.filenames_to_load, index) + live.eval(definition_name..' = nil') -- ignore errors which will likely be from keywords like `function = nil` + love.filesystem.remove(Live.filename[definition_name]) + Live.filename[definition_name] = nil + end elseif cmd == 'GET' then - local definition_name = buf:match('^%S+%s+(%S+)') + local definition_name = buf:match('^%s*%S+%s+(%S+)') local val, _ = live.get_binding(definition_name) if val then live.send_to_driver(val) @@ -262,18 +197,21 @@ function live.run(buf) live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.') return end - local next_filename = live.versioned_filename(Live.next_version, definition_name) - love.filesystem.write(next_filename, buf) - table.insert(Live.history, definition_name) - Live.manifest[definition_name] = Live.next_version - live.roll_forward() local status, err = live.eval(buf) if not status then - live.roll_back() -- throw an error live.send_to_driver('ERROR '..tostring(err)) return end + -- eval succeeded without errors; persist the definition + local filename = Live.filename[definition_name] + if filename == nil then + Live.final_prefix = Live.final_prefix+1 + filename = ('%04d-%s'):format(Live.final_prefix, definition_name) + table.insert(Live.filenames_to_load, filename) + Live.filename[definition_name] = filename + end + love.filesystem.write(filename, buf) -- run all tests Test_errors = {} App.run_tests(record_error_by_test) @@ -281,31 +219,13 @@ function live.run(buf) end end --- update Live.Head and record the new Live.Manifest (which caller has already modified) -function live.roll_forward() - Live.manifest[PARENT] = Live.head - local manifest_filename = live.versioned_manifest(Live.next_version) - love.filesystem.write(manifest_filename, json.encode(Live.manifest)) - Live.head = Live.next_version - love.filesystem.write('head', tostring(Live.head)) - Live.next_version = Live.next_version + 1 -end - --- update app.Head and reload app.Manifest appropriately -function live.roll_back() - Live.head = Live.manifest[PARENT] - love.filesystem.write('head', tostring(Live.head)) - local previous_manifest_filename = live.versioned_manifest(Live.head) - Live.manifest = json.decode(love.filesystem.read(previous_manifest_filename)) -end - function live.get_cmd_from_buffer(buf) return buf:match('^%s*(%S+)') end function live.get_binding(name) - if Live.manifest[name] then - return love.filesystem.read(live.versioned_filename(Live.manifest[name], name)) + if Live.filename[name] then + return love.filesystem.read(Live.filename[name]) end end diff --git a/representation.md b/representation.md deleted file mode 100644 index edec3ba..0000000 --- a/representation.md +++ /dev/null @@ -1,74 +0,0 @@ -# The on-disk representation of freewheeling apps - -When you start up a freewheeling app, you'll see a directory printed out in -the parent terminal (always launch it from a terminal window): - -``` -new edits will go to /home/... -``` - -When editing such an app using the driver (see [README.md](README.md)), new -definitions will go into this directory. Let's call it `$SAVE_DIR` in the rest -of this doc. - -It is always safe to move such definitions into this repo. (We'll call it `.` -in the rest of this doc.) You'll want to do this if you're sharing them with -others, and it's also helpful if the driver crashes on your app. Moving -definitions will never, ever change app behavior. - -```sh -$ mv -i $SAVE_DIR/[0-9]* . # should never clobber any existing files -$ mv $SAVE_DIR/head . # expected to clobber the existing file -``` - -Try looking inside the `head` file with a text editor. It'll contain a number, -the current version of the _manifest_ for this app. For example: - -``` -478 -``` - -This means the current state of the app is in a file called `0478-fwmanifest`. -If you moved the files you should see such a file in `.`. If you open this -file, you'll see a JSON table from definition names to version ids. For -example: - -``` -{ "a": 273, "b": 478} -``` - -This means the current definition of `a` is in `0273-a` and of `b` in -`0478-b`. - -Poking around these files gets repetitive, so there's a tool to streamline -things: - -``` -lua tools/stitch-live.lua 0478-fwmanifest -``` - -`stitch-live.lua` takes a manifest file as its argument, and prints out all -the definitions that make up the app at that version. - -To compare two versions of the app, use `stitch-live.lua` to copy the -definitions in each into a separate file, and use a file comparison tool (e.g. -`diff`) to compare the two files. - -# Scenarios considered in designing this representation - -* Capture history of changes. - - Though it is perhaps too fine-grained and noisy. - -* Merge changes from non-live forks to live ones. - - New files in repo can't hide changes in save dir, because filenames are - always disjoint between the two. - - This doesn't apply yet to live updates. Two forks of a single live app - will likely have unnecessary merge conflicts. - -* No special tools required to publish changes to others. - - Just move files from save dir to repo. - -# Scenarios I _would_ like to take into consideration in the future - -* Cleaner commits; it's clear what changed. -* merge changes between live forks. diff --git a/tools/stitch-live.lua b/tools/stitch-live.lua deleted file mode 100644 index 7ce916f..0000000 --- a/tools/stitch-live.lua +++ /dev/null @@ -1,37 +0,0 @@ -json = require 'json' - -function main(args) - local infile = io.open(args[1]) - local manifest_s = infile:read('*a') - infile:close() - local manifest = json.decode(manifest_s) - local core_filenames = {} - for k,v in pairs(manifest) do - if not starts_with(k, 'fw_') then - table.insert(core_filenames, k) - end - end - table.sort(core_filenames) - for _,core in ipairs(core_filenames) do - local filename = ('%04d'):format(manifest[core])..'-'..core - local f = io.open(filename) - if f then - print(f:read('*a')) - print('') - end - end -end - -function starts_with(s, prefix) - if #s < #prefix then - return false - end - for i=1,#prefix do - if s:sub(i,i) ~= prefix:sub(i,i) then - return false - end - end - return true -end - -main(arg)