Collaborative wiki for a few of us. Fork of lines.love.
Recently_modified_lookback_window = 100  -- how many notes to populate the 'recently modified' column with

-- keep sync'd with Edge_list
Opposite = {
  next='previous',
  previous='next',
  side='previous',  -- experiment; you can unroll from a side thread and lose the current note
  child='parent',
  parent='child',
  cross='cross',
  before='after',
  after='before',
  link='backlink',
  backlink='link',
}

-- keep sync'd with Opposite
Edge_list = {'previous', 'next', 'side', 'child', 'parent', 'cross', 'before', 'after', 'link', 'backlink'}

-- Most link types/labels are unique; a node can have only one link with each type.
-- However there are some exceptions.
-- The opposite of a unique link (e.g. parent) might be non-unique (e.g. child).
Non_unique_links = {'side', 'child', 'cross', 'before', 'after'}

-- Candidate commands to show in in the command palette in different contexts.
-- Ideally we'd have rules for:
--   contexts to show a command at
--   order in which to show commands for each context
-- But I don't want to design that, so I'm just going to embrace a
-- combinatorial explosion of duplication for a while.
Commands = {
  normal={
    'capture',
    'edit note at cursor (ctrl+e)',
    'maximize note',
    'close column surrounding cursor',
    'add (___) (create immediately link)',
    'step (___) (open link in new column)',
    'extract (open note in new column)',
    'unroll (___) (repeatedly step from cursor; unique only)',
    'append (___) (repeatedly step, then add; unique only)',
    'neighbors (open all links in new column)',
    'down one pane (ctrl+down)',
    'up one pane (ctrl+up)',
    'top pane of column (ctrl+home)',
    'bottom pane of column (ctrl+end)',
    'left one column (ctrl+left)',
    'right one column (ctrl+right)',
    'grab (temporary second cursor for some commands)',
    'ungrab (clear second cursor)',
    'link (___) (to second cursor)',
    'copy id (of current node to clipboard)',
    'rename link ___ (to) ___ (some other label)',
    'clear link ___ (use with care! ignores opposite link)',
    'move ___ (move column around cursor to right of given index or column 1 by default)',
    'wider columns (X)',
    'narrower columns (x)',
    'recently modified',
    'errors',
    'open file ___',
    'find on surface (ctrl+f)',
    'search (all notes)',
    'reload all from disk',
    'delete note at cursor from disk (if possible)',
    'copy selection to clipboard (ctrl+c)',
    'debug stats (toggle)',
    'snapshot summary of memory use to disk',
  },
  editable={
    'exit editing (ctrl+e)',
    'find in note (ctrl+f)',
    'copy selection to clipboard (ctrl+c)',
    'cut selection to clipboard (ctrl+x)',
    'paste from clipboard (ctrl+v)',
    'undo (ctrl+z)',
    'redo (ctrl+y)',
    'cursor to next word (alt+right arrow)',
    'cursor to previous word (alt+left arrow)',
    'capture',
    'maximize note',
    'close column surrounding cursor',
    'add (___) (create immediately link)',
    'step (___) (open link in new column)',
    'unroll (___) (repeatedly step from cursor; unique only)',
    'append (___) (repeatedly step, then add; unique only)',
    'neighbors (open all links in new column)',
    'down one pane (ctrl+down)',
    'up one pane (ctrl+up)',
    'top pane of column (ctrl+home)',
    'bottom pane of column (ctrl+end)',
    'wider columns (X)',
    'narrower columns (x)',
    'recently modified',
    'reload all from disk',
    'snapshot summary of memory use to disk',
  },
  maximized={
    'back to surface',
    'edit note (ctrl+e)',
    'add (___) (create immediately link)',
    'step (___) (open link in new column)',
    'append (___) (repeatedly step, then add; unique only)',
    'copy selection to clipboard (ctrl+c)',
  },
  maximized_editable={
    'exit editing (ctrl+e)',
    'back to surface',
    'find in note (ctrl+f)',
    'add (___) (create immediately link)',
    'step (___) (open link in new column)',
    'append (___) (repeatedly step, then add; unique only)',
    'copy selection to clipboard (ctrl+c)',
    'cut selection to clipboard (ctrl+x)',
    'paste from clipboard (ctrl+v)',
    'undo (ctrl+z)',
    'redo (ctrl+y)',
    'cursor to next word (alt+right arrow)',
    'cursor to previous word (alt+left arrow)',
  },
}

-- We incrementally create the menu based on context. Menu_cursor tracks how
-- far along the screen width we've gotten.
Menu_cursor = 0
Palette_cursor = {y=0, x=0}
Palette_alternatives_height = 5  -- number of rows of options to show

function draw_menu_bar()
  if App.run_tests then return end  -- disable in tests
  App.color(Menu_background_color)
  love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height)
  App.color(Menu_border_color)
  love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height)
  if Display_settings.palette then
    -- TODO: continue to put shortcuts on the menu bar, enter commands/search strings one row down
    return
  end
  App.color(Menu_command_color)
  Menu_cursor = 5
  add_hotkey_to_menu('ctrl+enter: search commands...')
  App.color(Menu_border_color)
  love.graphics.line(Menu_cursor-10,2, Menu_cursor-10,Menu_status_bar_height-2)
  if Display_settings.state.id == 'search' then
    add_hotkey_to_menu('esc: cancel')
    add_hotkey_to_menu('up: next match')
    add_hotkey_to_menu('down: previous match')
    add_hotkey_to_menu('ctrl+v: paste')
    return
  end
  if Cursor_pane.col >= 1 then
--?     print(Cursor_pane.col, Cursor_pane.row, #Surface)
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane then
      if Display_settings.state.id == 'normal' then
        if not pane.editable then
          local left_sx = left_edge_sx(Cursor_pane.col)
          local up_sy = up_edge_sy(Cursor_pane.col, Cursor_pane.row)
          if should_show_column(left_sx) and should_show_pane(pane, up_sy) then
            add_hotkey_to_menu('ctrl+e: edit')
            add_hotkey_to_menu('ctrl+f: find on surface')
            add_panning_hotkeys_to_menu()
          end
          add_hotkey_to_menu('x/X: narrower/wider columns')
        else
          if pane.cursor_x == nil then
            add_panning_hotkeys_to_menu()
          else
            assert(pane.cursor_y, 'cursor fell off viewport')
            add_hotkey_to_menu('ctrl+e: stop editing')
            add_hotkey_to_menu('ctrl+h on drawing: help')
            add_hotkey_to_menu('ctrl+f: find')
            add_hotkey_to_menu('alt+left alt+right: prev/next word')
            add_hotkey_to_menu('ctrl+z ctrl+y: undo/redo')
            add_hotkey_to_menu('ctrl+x ctrl+c ctrl+v: cut/copy/paste')
          end
        end
      end
    end
  end
  add_hotkey_to_menu('ctrl+= ctrl+- ctrl+0: zoom')
end

function add_panning_hotkeys_to_menu()
  add_hotkey_to_menu('arrows shift+arrows ctrl+up/down: pan')
end

function add_hotkey_to_menu(s)
  local width = Display_settings.font:getWidth(s)
  if Menu_cursor > App.screen.width - 30 then
    return
  end
  App.color(Menu_command_color)
  App.screen.print(s, Menu_cursor,5)
  Menu_cursor = Menu_cursor + width + 30
end

function keychord_press_on_command_palette(chord, key)
  if chord == 'escape' then
    -- forget text for next command
    Display_settings.palette = nil
  elseif chord == 'backspace' then
    local len = utf8.len(Display_settings.palette.command)
    local byte_offset = Text.offset(Display_settings.palette.command, len)
    Display_settings.palette.command = string.sub(Display_settings.palette.command, 1, byte_offset-1)
    Display_settings.palette.alternative_index = 1
    Display_settings.palette.candidates = candidates()
  elseif chord == 'tab' then
    -- select top candidate, but don't submit
    local p = Display_settings.palette
    p.command = command_string(p.candidates[p.alternative_index])
  elseif chord == 'C-v' then
    local p = Display_settings.palette
    p.command = p.command..App.get_clipboard()
    p.candidates = candidates()
  elseif chord == 'return' then
    -- submit selected candidate
    local p = Display_settings.palette
    local candidates = Display_settings.palette.candidates
    if #p.candidates > 0 then
      if file_exists(Directory..p.candidates[p.alternative_index]) then
        command.open_file_in_next_column(p.candidates[p.alternative_index])
      else
        run_command(command_string(p.candidates[p.alternative_index]))
      end
    else
      -- try to run the command as if it contains args
      run_command_with_args(p.command)
    end
    -- forget text for next command
    Display_settings.palette = nil
    -- clean up some columns if possible
    if Cursor_pane.col < 45 then
      while #Surface > 50 do
        print_and_log('keychord_press (palette) return: dropping '..Surface[#Surface].name)
        table.remove(Surface)
      end
    end
  elseif chord == 'up' then
    if Display_settings.palette.alternative_index > 1 then
      Display_settings.palette.alternative_index = Display_settings.palette.alternative_index-1
    end
  elseif chord == 'down' then
    if Display_settings.palette.alternative_index < #Display_settings.palette.candidates then
      Display_settings.palette.alternative_index = Display_settings.palette.alternative_index+1
    end
  elseif chord == 'left' then
    if Display_settings.palette.alternative_index > Palette_alternatives_height then
      Display_settings.palette.alternative_index = Display_settings.palette.alternative_index-Palette_alternatives_height
    end
  elseif chord == 'right' then
    if Display_settings.palette.alternative_index <= #Display_settings.palette.candidates-Palette_alternatives_height then
      Display_settings.palette.alternative_index = Display_settings.palette.alternative_index+Palette_alternatives_height
    end
  end
end

function command_string(s)
  local result, _ = s:gsub(' %(.*', ''):gsub(' _.*', '')
  return result
end

function draw_command_palette()
  -- background
  App.color(Command_palette_background_color)
  love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height)
  App.color(Command_palette_border_color)
  love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height)
  -- input box
  App.color(Command_palette_command_color)
  draw_palette_input(5, 5)
  -- alternatives
  App.color(Command_palette_alternatives_background_color)
  love.graphics.rectangle('fill', 0, Menu_status_bar_height, App.screen.width, 5+Palette_alternatives_height*Line_height+5)
  App.color(Command_palette_border_color)
  love.graphics.rectangle('line', 0, Menu_status_bar_height, App.screen.width, 5+Palette_alternatives_height*Line_height+5)
  Palette_cursor = {y=Menu_status_bar_height+5, x=5, nextx=5}
  for i,cmd in ipairs(Display_settings.palette.candidates) do
    add_command_to_palette(cmd, i == Display_settings.palette.alternative_index)
  end
end

function draw_command_palette_for_search_all()
  -- background
  App.color(Command_palette_background_color)
  love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height)
  App.color(Command_palette_border_color)
  love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height)
  -- input box
  App.color(Command_palette_command_color)
  local x = 5
  local y = 5
  love.graphics.print(Display_settings.state.search_all_query, x,y)
  if Display_settings.state.id == 'search_all' then
    -- draw cursor
    x = x+Display_settings.font:getWidth(Display_settings.state.search_all_query)
    draw_cursor(x, y)
  elseif Display_settings.state.id == 'searching_all' then
    -- show progress
    App.color(Command_palette_alternatives_background_color)
    love.graphics.rectangle('fill', 0, Menu_status_bar_height, App.screen.width, 5+Line_height+5)
    App.color(Command_palette_border_color)
    love.graphics.rectangle('line', 0, Menu_status_bar_height, App.screen.width, 5+Line_height+5)
    App.screen.print(Display_settings.state.search_all_progress_indicator, --[[x]] 5, --[[y]] Menu_status_bar_height+5)
  end
end

function add_command_to_palette(s, cursor_highlight)
  local width = Display_settings.font:getWidth(s)
  if Palette_cursor.x + width/2 > App.screen.width - 5 then
    return
  end
  if cursor_highlight then
    App.color(Command_palette_highlighted_alternative_background_color)
  else
    App.color(Command_palette_alternatives_background_color)
  end
  love.graphics.rectangle('fill', Palette_cursor.x-5, Palette_cursor.y, width+10, Line_height)
  App.color(Command_palette_alternatives_color)
  App.screen.print(s, Palette_cursor.x, Palette_cursor.y)
  Palette_cursor.nextx = math.max(Palette_cursor.nextx, Palette_cursor.x+width+10)
  Palette_cursor.y = Palette_cursor.y + Line_height
  if Palette_cursor.y >= Menu_status_bar_height + 5+Palette_alternatives_height*Line_height then
    App.color(Command_palette_border_color)
    love.graphics.line(Palette_cursor.nextx, Menu_status_bar_height+2, Palette_cursor.nextx, Menu_status_bar_height + 5+5*Line_height+5)
    Palette_cursor.x = Palette_cursor.nextx + 5
    Palette_cursor.y = Menu_status_bar_height+5
  end
end

function draw_palette_input(x, y)
  love.graphics.print(Display_settings.palette.command, x,y)
  x = x+Display_settings.font:getWidth(Display_settings.palette.command)
  draw_cursor(x, y)
end

function draw_cursor(x, y)
  -- blink every 0.5s
  if math.floor(Cursor_time*2)%2 == 0 then
    App.color(Cursor_color)
    love.graphics.rectangle('fill', x,y, 3,Line_height)
  end
end

function candidates()
  -- slight context-sensitive tweaks
  local candidates = initial_candidates()
  if Display_settings.palette.command == '' then
    return candidates
  elseif Display_settings.palette.command:sub(1,1) == '/' then
    return {}
  else
    local results = filter_candidates(candidates, Display_settings.palette.command)
    if Display_settings.state.id == 'normal' then
      append(results, file_candidates(Display_settings.palette.command))
    end
    return results
  end
end

function initial_candidates()
  if Cursor_pane.col < 1 then
    return Commands.normal
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    return Commands.normal
  end
  if Display_settings.state.id == 'normal' then
    if not pane.editable then
      return Commands.normal
    else
      return Commands.editable
    end
  elseif Display_settings.state.id == 'maximize' then
    if not pane.editable then
      return Commands.maximized
    else
      return Commands.maximized_editable
    end
  end
end

function filter_candidates(candidates, prefix)
  local result = {}
  for _,cand in ipairs(candidates) do
    if cand:find(prefix, 1, --[[literal pattern]] true) == 1 then
      table.insert(result, cand)
    end
  end
  return result
end

function file_candidates(prefix)
--?   print('-- '..prefix)
  local info = App.file_info(Directory..prefix)
--?   print(info)
--?   if info then
--?     print(info.type)
--?   end
  if info and info.type == 'file' then
    return {prefix}
  end
  local path = Directory
  local visible_dir = ''
  if info and info.type == 'directory' then
    if prefix:sub(#prefix) == '/' then
      visible_dir = prefix
    else
      visible_dir = prefix..'/'
    end
    path = path..visible_dir
  elseif prefix:find('/') then
    visible_dir = dirname(prefix)
    path = path..visible_dir
  end
--?   print('path:', path)
  local files = App.files(path)
--?   print(#files, 'files')
  local base
  if info and info.type == 'directory' then
    base = ''
  else
    base = basename(prefix)
  end
  return concat_all(visible_dir, filter_candidates(reorder(path, files), base))
end

function reorder(dir, files)
  local result = {}
  local info = {}
  for _,file in ipairs(files) do
    info[file] = App.file_info(dir..'/'..file)
  end
  -- files before directories
  for _,file in ipairs(files) do
    if info[file].type ~= 'directory' then
      table.insert(result, file)
    end
  end
  for _,file in ipairs(files) do
    if info[file].type == 'directory' then
      table.insert(result, file..'/')
    end
  end
  return result
end

function run_command(cmd, args)
  if cmd == 'capture' then
    command.capture()
  elseif cmd == 'maximize note' then
    command.maximize_note()
  elseif cmd == 'back to surface' then
    command.back_to_surface()
  elseif cmd == 'edit note' or cmd == 'edit note at cursor' then
    command.edit_note()
  elseif cmd == 'exit editing' then
    command.exit_editing()
  elseif cmd == 'close column surrounding cursor' then
    command.close_column_surrounding_cursor()
  elseif cmd == 'grab' then
    command.grab()
  elseif cmd == 'ungrab' then
    command.ungrab()
  elseif cmd == 'link' then
    command.link(args)
  elseif cmd == 'copy id' then
    command.copy_id_to_clipboard()
  elseif cmd == 'rename link' then
    command.rename_link(args)
  elseif cmd == 'clear link' then
    command.clear_link(args)
  elseif cmd == 'add' then
    command.add_note(args)
  elseif cmd == 'step' then
    command.step(args)
  elseif cmd == 'extract' then
    command.extract()
  elseif cmd == 'unroll' then
    command.unroll(args)
  elseif cmd == 'append' then
    command.append_note(args)
  elseif cmd == 'neighbors' then
    command.neighbors()
  elseif cmd == 'down one pane' then
    command.down_one_pane()
  elseif cmd == 'up one pane' then
    command.up_one_pane()
  elseif cmd == 'top pane of column' then
    command.top_pane_of_column()
  elseif cmd == 'bottom pane of column' then
    command.bottom_pane_of_column()
  elseif cmd == 'left one column' then
    command.left_one_column()
  elseif cmd == 'right one column' then
    command.right_one_column()
  elseif cmd == 'move' then
    command.move_column(args)
  elseif cmd == 'wider columns' then
    command.wider_columns()
  elseif cmd == 'narrower columns' then
    command.narrower_columns()
  elseif cmd == 'recently modified' then
    command.recently_modified()
  elseif cmd == 'errors' then
    command.errors()
  elseif cmd == 'open file' then
    command.open_file_in_next_column(args)
  elseif cmd == 'find on surface' then
    command.commence_find_on_surface()
  elseif cmd == 'search' then
    command.commence_search_in_disk()
  elseif cmd == 'reload all from disk' then
    command.reload_all()
  elseif cmd == 'delete note at cursor from disk' then
    command.delete_note()
  elseif cmd == 'debug stats' then
    Display_settings.show_debug = not Display_settings.show_debug
  elseif cmd == 'snapshot summary of memory use to disk' then
    command.snapshot_memory()
  -- editing
  elseif cmd == 'find in note' then
    command.send_key_to_current_pane('C-f', 'f')
  elseif cmd == 'copy selection to clipboard' then
    command.send_key_to_current_pane('C-c', 'c')
  elseif cmd == 'cut selection to clipboard' then
    command.send_key_to_current_pane('C-x', 'x')
  elseif cmd == 'paste from clipboard' then
    command.send_key_to_current_pane('C-v', 'v')
  elseif cmd == 'undo' then
    command.send_key_to_current_pane('C-z', 'z')
  elseif cmd == 'redo' then
    command.send_key_to_current_pane('C-y', 'y')
  elseif cmd == 'cursor to next word' then
    command.send_key_to_current_pane('M-right', 'right')
  elseif cmd == 'cursor to previous word' then
    command.send_key_to_current_pane('M-left', 'left')
  else
    print_and_log(('run_command: not implemented yet: %s'):format(cmd))
  end
end

function run_command_with_args(cmd_with_args)
  for _,cand in ipairs(initial_candidates()) do
    cand = command_string(cand)
    local found_offset = cmd_with_args:find(cand, 1, --[[literal pattern]] true)
    if found_offset == 1 then
      local pivot = #cand+1
      if cmd_with_args:sub(pivot, pivot) == ' ' then
        run_command(cand, trim(cmd_with_args:sub(pivot)))
      end
      return
    end
  end
end

-- commands that create columns also need to be recreatable from a title
-- I'm assuming that special columns have multiple words, and single-word
-- columns are always filenames. techmeet.love won't ever create filenames
-- containing spaces.
function create_column(column_name)
  if file_exists(Directory..column_name) then
    local column = {name=column_name}
    local pane = load_pane(column_name)
    table.insert(column, pane)
    table.insert(Surface, column)
  elseif not column_name:find(' ') then
    -- File not found
    --
    -- It makes me nervous to silently drop errors, but at this point there's
    -- really nothing actionable someone can do in response to an error.
    --
    -- Deeper issue: no way yet to communicate errors in the UI.
    --
    -- Philosophical question: what does crash-only mean if you ever run into
    -- data loss? There's a hard tension between resilience and silent failures.
    --
    -- For now I'm going to rely on all my protections against data loss
    -- elsewhere. Lines.love has never lost my data in several months of use.
    --
    -- While data loss seems unlikely, there _is_ a legitimate way you can end
    -- up with a filename that doesn't exist: start a capture, then change
    -- your mind and never type anything into it. It will continue to show as
    -- a column on the surface, but there's no file backing it. You can still
    -- edit it later and create a file for it. But if you just quit, the
    -- column will silently disappear after restart.
    print_and_log('create_column: file not found: '..column_name)
  else
    -- delegate to one of various helpers based on the column name
    local column = {name=column_name}
    populate_column(column)
    if #column == 0 then
      -- Something has changed from underneath us, likely between restarts;
      -- assume we already printed out an error.
      return
    end
    table.insert(Surface, column)
  end
end

function populate_column(column)
  if column.name == 'recently modified' then
    populate_recently_modified_column(column)
  elseif column.name == 'errors' then
    populate_errors_column(column)
  elseif string.match(column.name, '%S+ from %S+') then
    local rel, start_id = string.match(column.name, '(%S+) from (%S+)')
    populate_unroll_column(column, start_id, rel)
  elseif string.match(column.name, 'neighbors of %S+') then
    local start_id = string.match(column.name, 'neighbors of (%S+)')
    populate_neighbors_column(column, start_id)
  elseif string.match(column.name, '%S+ of %S+') then
    local rel, start_id = string.match(column.name, '(%S+) of (%S+)')
    populate_step_column(column, start_id, rel)
  elseif string.match(column.name, 'search: .+') then
--?     print('column name', column.name)
    local search_all_query = string.match(column.name, 'search: (.+)')
--?     print('search term', search_all_query)
    populate_search_all_column(column, search_all_query)
  else
    error("don't know how to populate column \""..column.name.."\"")
  end
end

command = {}

function command.capture()
  local pane = new_pane()
  local column = {name=pane.id}
  table.insert(column, pane)
  table.insert(Surface, Cursor_pane.col+1, column)
  Cursor_pane.col = Cursor_pane.col+1
  Cursor_pane.row = 1
  bring_cursor_of_cursor_pane_in_view('up')
  stop_editing_all()
  pane.editable = true
  command.maximize_note()
end

function command.maximize_note()
  Display_settings.state = {id = 'maximize'}
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane then
      pane.top = Header_height + Margin_above
      pane.left = App.screen.width/2 - 20*pane.font:getWidth('m')
      pane.right = App.screen.width/2 + 20*pane.font:getWidth('m')
      pane.width = pane.right - pane.left
      Text.redraw_all(pane)
    end
  end
end

function command.back_to_surface()
  Display_settings.state = {id = 'normal'}
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane then
      refresh_pane_height(pane)
      plan_draw()
    end
  end
end

function command.close_column_surrounding_cursor()
  stop_editing_all()
  table.remove(Surface, Cursor_pane.col)
  if Cursor_pane.col > 1 then
    Cursor_pane.col = Cursor_pane.col - 1
    Cursor_pane.row = 1
  end
  bring_cursor_of_cursor_pane_in_view('up')
  plan_draw()
end

function command.edit_note()
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  assert(not pane.editable, 'pane already editable')
  stop_editing_all()
  pane.recent_updated = false
  pane.editable = true
  if Text.lt1(pane.cursor1, pane.screen_top1) then
    pane.cursor1 = {line=pane.screen_top1.line, pos=pane.screen_top1.pos}
  end
end

function command.exit_editing()
  assert(Cursor_pane.col >= 1, 'no current pane')
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  assert(pane, 'no current pane')
  assert(pane.editable, 'current pane not editable')
  stop_editing(pane)
end

function command.down_one_pane()
  if Cursor_pane.row < #Surface[Cursor_pane.col] then
    Cursor_pane.row = Cursor_pane.row + 1
  end
  Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical
  plan_draw()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  pane.cursor1 = {line=1, pos=1}
  edit.put_cursor_on_next_text_line(pane)
end

function command.up_one_pane()
  if Cursor_pane.row > 1 then
    Cursor_pane.row = Cursor_pane.row - 1
  end
  Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical
  plan_draw()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  pane.cursor1 = {line=1, pos=1}
  edit.put_cursor_on_next_text_line(pane)
end

function command.bottom_pane_of_column()
  if Cursor_pane.row < #Surface[Cursor_pane.col] then
    Cursor_pane.row = #Surface[Cursor_pane.col]
  end
  Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical
  plan_draw()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  pane.cursor1 = {line=1, pos=1}
  edit.put_cursor_on_next_text_line(pane)
end

function command.top_pane_of_column()
  if Cursor_pane.row > 1 then
    Cursor_pane.row = 1
  end
  Display_settings.y = up_edge_sy(Cursor_pane.col, Cursor_pane.row) - Padding_vertical
  plan_draw()
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  pane.cursor1 = {line=1, pos=1}
  edit.put_cursor_on_next_text_line(pane)
end

-- imperfect commands for left/right navigation
-- these can be improved
function command.right_one_column()
  if Cursor_pane.col >= #Surface then
    return
  end
  Cursor_pane.col = Cursor_pane.col + 1
  Cursor_pane.row = 1  -- always bounce back to top of column
  Display_settings.y = 0
  local xlo = left_edge_sx(Cursor_pane.col) - Margin_left - Padding_horizontal
  Display_settings.x = math.max(xlo, Display_settings.x)
  local xmax = left_edge_sx(Cursor_pane.col+1) - App.screen.width
  Display_settings.x = math.min(xmax, Display_settings.x)
  Display_settings.x = math.max(0, Display_settings.x)
  plan_draw()
end

function command.left_one_column()
  if Cursor_pane.col <= 1 then
    return
  end
  Cursor_pane.col = Cursor_pane.col - 1
  Cursor_pane.row = 1  -- always bounce back to top of column
  Display_settings.y = 0
  local xlo = left_edge_sx(Cursor_pane.col) - Margin_left - Padding_horizontal
  Display_settings.x = math.min(xlo, Display_settings.x)
  plan_draw()
end

-- move column to _after_ index
function command.move_column(index)
  if index == nil then index = 1 end
  if Cursor_pane.col < 1 then
    add_error('no current column')
    return
  end
  local column = Surface[Cursor_pane.col]
  table.remove(Surface, Cursor_pane.col)
  if Cursor_pane.col < index+1 then
    index = index-1  -- less confusing to treat 'index' as it was before the table.remove
  end
  table.insert(Surface, index+1, column)
  Cursor_pane.col = index+1
  bring_cursor_of_cursor_pane_in_view('down')
  plan_draw()
end

function command.wider_columns()
  Display_settings.column_width = Display_settings.column_width + 5*Display_settings.font:getWidth('m')
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      pane.left = 0
      pane.right = Display_settings.column_width
      pane.width = pane.right - pane.left
    end
  end
  clear_all_pane_heights()
  plan_draw()
end

function command.narrower_columns()
  Display_settings.column_width = Display_settings.column_width - 5*Display_settings.font:getWidth('m')
  for _,column in ipairs(Surface) do
    for _,pane in ipairs(column) do
      pane.left = 0
      pane.right = Display_settings.column_width
      pane.width = pane.right - pane.left
    end
  end
  clear_all_pane_heights()
  plan_draw()
end

function command.recently_modified()
  if not file_exists(Directory..'recent') then
    return
  end
  local column = {name='recently modified'}
  populate_recently_modified_column(column)
  add_column_to_right_of_cursor(column)
  plan_draw()
end

function populate_recently_modified_column(column)
  local filenames = {}
  local f = App.open_for_reading(Directory..'recent')
  for line in f:lines() do
    table.insert(filenames, line)
  end
  f:close()
  local done, ndone = {}, 0
  for i=#filenames,1,-1 do
    local filename = filenames[i]
    if ndone >= Recently_modified_lookback_window then break end
    if not done[filename] then
      done[filename] = true
      ndone = ndone+1
--?       print('loading', filename)
      local pane = load_pane(filename)
      table.insert(column, pane)
    end
  end
end

function command.errors()
  local column = {name='errors'}
  populate_errors_column(column)
  add_column_to_right_of_cursor(column)
  plan_draw()
end

function populate_errors_column(column)
  -- TODO: we might run into some bugs if we have multiple error panes visible
  -- on the surface; unlike everything else these currently alias.
  local pane = Error_log
  pane.font_height = Font_height
  pane.line_height = Line_height
  pane.editable = false
  edit.check_locs(pane)
  pane.title = '(do not edit)'
  Text.redraw_all(pane)
  table.insert(column, pane)
end

function command.open_file_in_next_column(filename)
  local column = {name=filename}
  local pane = load_pane(filename)
  table.insert(column, pane)
  add_column_to_right_of_cursor(column)
  plan_draw()
end

function command.commence_find_on_surface()
  -- save some state
  clear_selections()
  Display_settings.state = {
    id='search',
    search_term = '',
    search_backup_x = Display_settings.x,
    search_backup_y = Display_settings.y,
    search_backup_cursor_pane = deepcopy(Cursor_pane),
  }
  -- prepare to pass Display_settings.state to Text.draw_search_bar
--?   print('entering search mode')
  Display_settings.state.font = Display_settings.font
  Display_settings.state.line_height = Line_height
end

function command.commence_search_in_disk()
  Display_settings.state = {
    id='search_all',
    search_all_pane = initialize_search_all_pane(),
    search_all_query = '',
    search_all_progress_indicator = 'starting search...',
  }
end

-- search panes are opposites of regular panes
--   regular pane: pass in id, load from disk, may be edited
--   search pane: create without id, initialize id after search term is typed in, create empty file, slowly append to disk, may not be edited
function initialize_search_all_pane()
  local result = edit.initialize_state(0, 0, math.min(Display_settings.column_width, App.screen.width-Margin_right), love.graphics.getFont(), Font_height, Line_height)
  result.font_height = Font_height
  result.line_height = Line_height
  result.editable = false
  Text.redraw_all(result)
  return result
end

function finalize_search_all_pane()
  if Display_settings.state.search_all_query:sub(1,1) == '"' then
    -- support only a single quoted phrase by itself
    assert(Display_settings.state.search_all_query:sub(#Display_settings.state.search_all_query) == '"', 'you can search for strings in quotes, but only one of them by itself')
    Display_settings.state.search_all_terms = {Display_settings.state.search_all_query:sub(2, #Display_settings.state.search_all_query-1)}
  else
    Display_settings.state.search_all_terms = split(Display_settings.state.search_all_query)
  end
  local id = 'search'
--?   print(id)
  App.remove(Directory..id)
  Display_settings.state.search_all_pane.id = id
  Display_settings.state.search_all_pane.filename = Directory..id
  Display_settings.editable = false
end

function add_search_all_pane_to_right_of_cursor()
  local column = {name='search: '..Display_settings.state.search_all_query}
  table.insert(column, Display_settings.state.search_all_pane)
  add_column_to_right_of_cursor(column)
end

function resume_search_all()
  -- make a little more progress towards searching the whole disk
  if Display_settings.state.search_all_progress == nil then
    Display_settings.state.search_all_progress_indicator = 'initialized top-level files'
    Display_settings.state.search_all_progress = {
      top_level_files = App.files(Directory),
      top_level_file_index = 1,
    }
  elseif Display_settings.state.search_all_progress.top_level_file_index then
    local current_filename = Display_settings.state.search_all_progress.top_level_files[Display_settings.state.search_all_progress.top_level_file_index]
    Display_settings.state.search_all_progress_indicator = current_filename
    if current_filename ~= 'search' and current_filename ~= 'config' and current_filename ~= 'recent' then -- ignore some housekeeping files for pensieve.love
      local info = App.file_info(Directory..current_filename)
      if info.type == 'file' then
        search_in_file(current_filename)
      end
    end
    Display_settings.state.search_all_progress.top_level_file_index = Display_settings.state.search_all_progress.top_level_file_index+1
    if Display_settings.state.search_all_progress.top_level_file_index > #Display_settings.state.search_all_progress.top_level_files then
      Display_settings.state.search_all_progress.top_level_file_index = nil
      Display_settings.state.search_all_progress.top_level_files = nil
      Display_settings.state.search_all_progress.time = os.time()
      Display_settings.state.search_all_progress.year = os.date('%Y', Display_settings.state.search_all_progress.time)
      Display_settings.state.search_all_progress.date = os.date('%Y/%m/%d/', Display_settings.state.search_all_progress.time)
    end
  elseif Display_settings.state.search_all_progress.date then
    -- search one day's directory per frame
    -- stop when a whole year is missing
    Display_settings.state.search_all_progress_indicator = Display_settings.state.search_all_progress.date
    local old_year = Display_settings.state.search_all_progress.year
    local date_dir = Directory..Display_settings.state.search_all_progress.date
    local info = App.file_info(date_dir)
    if info then
      if info.type == 'directory' then
        local filenames = App.files(date_dir)
        for _,filename in ipairs(filenames) do
          -- hack: to speed up search, only search files created/managed by Pensieve
          -- I often have other stuff here that's a lot larger (email).
          if filename:match('^%d%d%-%d%d%-%d%d$') then
--?             print(date_dir..filename)
            search_in_file(Display_settings.state.search_all_progress.date..filename)
          end
        end
      end
    end
    Display_settings.state.search_all_progress.time = Display_settings.state.search_all_progress.time - 24*60*60
    Display_settings.state.search_all_progress.year = os.date('%Y', Display_settings.state.search_all_progress.time)
    Display_settings.state.search_all_progress.date = os.date('%Y/%m/%d/', Display_settings.state.search_all_progress.time)
    if old_year ~= Display_settings.state.search_all_progress.year then
      local previous_year_info = App.file_info(Directory..Display_settings.state.search_all_progress.year)
      if previous_year_info == nil then
        Display_settings.state = {id = 'normal'}
      end
    end
  else
    assert(false, 'error in search state machine')
  end
end

function search_in_file(filename)
--?   print('searching '..filename..' for '..Display_settings.state.search_all_query)
  local contents, err = App.read_file(Directory..filename)
  if err then
    error(err)
  end
  if contents == nil then
    error('no contents in '..filename)
  end
  if match_all(contents, Display_settings.state.search_all_terms) then
    local outfilename = Directory..'search'
    local success, errmsg = append_to_file(outfilename, '[['..filename..']]\n')
    if not success then error(errmsg) end
    local index = 0
    while true do
      index = find_any(contents, Display_settings.state.search_all_terms, index+1)
      if index == nil then
        break
      end
      local start_offset = find_previous_byte(contents, '\n', index)
      local end_offset = contents:find('\n', index, --[[literal]] true)
      local snippet = contents:sub(start_offset, end_offset)
      local success, errmsg = append_to_file(outfilename, '...'..snippet..'...\n\n')
      if not success then error(errmsg) end
    end
    load_from_disk(Display_settings.state.search_all_pane)
    Text.redraw_all(Display_settings.state.search_all_pane)
    refresh_pane_height(Display_settings.state.search_all_pane)
  end
end

function find_previous_byte(s, b, index)
  while index > 1 do
    if s:sub(index, index) == b then
      break
    end
    index = index-1
  end
  return index
end

function interrupt_search_all()
  if Display_settings.state.search_all_progress then
    local outfilename = Directory..'search'
    local success, errmsg = append_to_file(outfilename, 'interrupted at '..Display_settings.state.search_all_progress_indicator..'\n')
    if not success then error(errmsg) end
    load_from_disk(Display_settings.state.search_all_pane)
    Text.redraw_all(Display_settings.state.search_all_pane)
  end
  Display_settings.state = {id = 'normal'}
end

function populate_search_all_column(column, search_all_query)
  table.insert(column, load_pane('search'))
end

function command.reload_all()
  local column_names = {}
  for _,column in ipairs(Surface) do
    table.insert(column_names, column.name)
  end
  Surface = {}
  local old_viewport = {x=Display_settings.x, y=Display_settings.y}
  local old_cursor_pane = Cursor_pane
  Cursor_pane = {col=0,row=1}
  for _,column_name in ipairs(column_names) do
    create_column(column_name)
  end
  Cursor_pane = old_cursor_pane
  -- something's moving us around
  Display_settings.x = old_viewport.x
  Display_settings.y = old_viewport.y
  plan_draw()
end

function command.add_note(rel)
  if rel == nil then
    rel = 'next'
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if Links[pane.id][rel] and not array.find(Non_unique_links, rel) then
    add_error(('%s already has a %s note'):format(pane.id, rel))
    return
  end
  stop_editing_all()  -- save any edits
  if #Surface[Cursor_pane.col] == 1 and not array.find(Non_unique_links, rel) then
    -- column has a single note; turn it into unroll
    Surface[Cursor_pane.col] = {name=('%s from %s'):format(rel, pane.id)}
    populate_unroll_column(Surface[Cursor_pane.col], pane.id, rel)
  end
  local new_pane = new_pane()
  new_pane.editable = true
  -- connect up links
  add_link(pane.id, rel, new_pane.id)
  add_link(new_pane.id, Opposite[rel], pane.id)
  if string.match(Surface[Cursor_pane.col].name, rel..' from %S+') then
    -- we're unrolling along the same rel; just append to it
    -- (we couldn't be inserting in the middle if we didn't return earlier in
    -- the function)
    table.insert(Surface[Cursor_pane.col], new_pane)
    Cursor_pane.row = #Surface[Cursor_pane.col]
    add_title(new_pane, ('%d/%d'):format(Cursor_pane.row, Cursor_pane.row))
    refresh_pane_height(pane)  -- just in case this is the first link
    bring_cursor_of_cursor_pane_in_view('down')
  else
    local column = {name=new_pane.id}
    table.insert(column, new_pane)
    add_column_to_right_of_cursor(column)
    refresh_pane_height(pane)  -- just in case this is the first link
    bring_cursor_of_cursor_pane_in_view('up')
  end
  plan_draw()
end

function command.grab()
  if Cursor_pane.col >= 1 then
    local pane = Surface[Cursor_pane.col][Cursor_pane.row]
    if pane then
      Grab_pane = pane
    end
  end
end

function command.ungrab()
  Grab_pane = nil
end

function command.link(rel)
  if Grab_pane == nil then
    add_error('link: needs something to be in the secondary "grab" cursor but found nothing')
    return
  end
  if rel == nil then
    rel = 'next'
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if not can_add_link(pane.id, rel, Grab_pane.id) then
    return
  end
  if not can_add_link(Grab_pane.id, Opposite[rel], pane.id) then
    return
  end
  -- connect up links
  add_link(pane.id, rel, Grab_pane.id)
  add_link(Grab_pane.id, Opposite[rel], pane.id)
  schedule_save(pane)
  stop_editing(pane)
  refresh_pane_height(pane)  -- just in case this is the first link
  refresh_pane_height(Grab_pane)  -- just in case this is the first link
  Grab_pane = nil
  plan_draw()
end

-- Can we add a link called 'rel' from src to target?
function can_add_link(src, rel, target)
  print_and_log(('checking before adding link labeled %s from %s to %s'):format(rel, src, target))
  if array.find(Non_unique_links, rel) then
    -- check if already present
    if Links[src][rel] then
      for _,id in ipairs(Links[src][rel]) do
        if id == target then
          add_error(('%s is already a %s of %s'):format(target, rel, src))
          return false
        end
      end
    end
  else
    -- check for conflict
    if Links[src][rel] then
      add_error(('%s already has a %s note'):format(src, rel))
      return false
    end
  end
  return true
end

function add_link(src, rel, target)
  print_and_log(('adding link labeled %s from %s to %s'):format(rel, src, target))
  if array.find(Non_unique_links, rel) then
    if Links[src][rel] == nil then
      Links[src][rel] = {target}
    else
      table.insert(Links[src][rel], target)
    end
  else
  print_and_log('unique link')
    Links[src][rel] = target
  end
end

function links_state(id)
  local result = {}
  table.insert(result, id..' -- ')
  if Links[id] then
    for rel,val in pairs(Links[id]) do
      table.insert(result, rel)
      table.insert(result, ':')
      if type(val) == 'table' then
        table.insert(result, '[')
        for _,dest in ipairs(val) do
          table.insert(result, dest)
          table.insert(result, ' ')
        end
        table.insert(result, ']')
      else
        table.insert(result, val)
      end
      table.insert(result, '|')
    end
  end
  return table.concat(result)
end

function remove_link(src, rel, target)
--?   print(('removing %s of %s, to %s'):format(rel, src, target))
  if array.find(Non_unique_links, rel) then
--?     print(('%s is non-unique'):format(rel))
    local arr = Links[src][rel]
    assert(arr, 'found no link')
    assert(type(arr) == 'table', 'links not arranged in a table')
    local pos = array.find(arr, target)
--?     print(('contains %s at index %s'):format(target, pos))
    assert(pos, "couldn't find link to remove in links table")
    table.remove(arr, pos)
    if #arr == 0 then
      Links[src][rel] = nil
    end
  else
    assert(Links[src][rel] == target, 'link at this rel is not the target; giving up')
    Links[src][rel] = nil
  end
end

function command.copy_id_to_clipboard()
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  App.set_clipboard(pane.id)
end

function command.rename_link(args)
  local from, to = args:match('(%w+)%s+to%s+(%w+)')
  if from == nil then
    from, to = args:match('(%w+)%s+(%w+)')
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  local target
  if array.find(Non_unique_links, from) then
    if #Links[pane.id][from] <= 0 then
      add_error(('no %s links'):format(from))
      return
    end
    if #Links[pane.id][from] > 1 then
      add_error(('multiple %s links; not sure which one you mean'):format(from))
      return
    end
    target = Links[pane.id][from][1]
  else
    target = Links[pane.id][from]
  end
  print_and_log(('renaming link %s of %s to %s'):format(from, pane, to))
  if Links[pane.id][to] and not array.find(Non_unique_links, to) then
    add_error(('%s already has a %s note'):format(pane.id, to))
    return
  end
  -- forwards direction
  remove_link(pane.id, from, target)
  add_link(pane.id, to, target)
  if Opposite[to] ~= Opposite[from] then
    remove_link(target, Opposite[from], pane.id)
    add_link(target, Opposite[to], pane.id)
  end
  schedule_save(pane)
  stop_editing(pane)
end

function command.clear_link(rel)
  if rel == nil then
    add_error('specify a link to clear')
    return
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if Links[pane.id][rel] == nil then
    add_error(('%s has no %s note'):format(pane.id, rel))
    return
  end
  if type(Links[pane.id][rel]) == 'table' then
    if #Links[pane.id][rel] > 1 then
      add_error(('%s is a non-unique link; clearing all %d such links'):format(rel, #Links[pane.id][rel]))
    end
  end
  print_and_log(('clearing link %s of %s (used to point to %s)'):format(rel, pane.id, Links[pane.id][rel]))
  Links[pane.id][rel] = nil
  schedule_save(pane)
  stop_editing(pane)
  refresh_pane_height(pane)  -- just in case this is the final link
  plan_draw()
end

function command.step(rel)
  if rel == nil then
    rel = 'next'
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if Links[pane.id][rel] == nil then
    add_error(('%s has no %s note'):format(pane.id, rel))
    return
  end
  if array.find(Non_unique_links, rel) then
    local column = {name=(('%s of %s'):format(rel, pane.id))}
    populate_step_column(column, pane.id, rel)
    add_column_to_right_of_cursor(column)
    plan_draw()
  else
    command.open_file_in_next_column(Links[pane.id][rel])
  end
end

function populate_step_column(column, start_id, rel)
  if Links[start_id] == nil then
    Links[start_id] = load_links(start_id)
  end
  if Links[start_id][rel] == nil then
    return
  end
  local n = #Links[start_id][rel]
  for i, id in ipairs(Links[start_id][rel]) do
    local pane = load_pane(id)
    add_title(pane, ('%d/%d'):format(i, n))
    table.insert(column, pane)
  end
end

function command.extract(rel)
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  command.open_file_in_next_column(pane.id)
end

function command.unroll(rel)
  if rel == nil then
    rel = 'next'
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  local column = {name=('%s from %s'):format(rel, pane.id)}
  populate_unroll_column(column, pane.id, rel)
  if #column == 0 then
    return
  end
  if #Surface[Cursor_pane.col] == 1 then
    assert(Cursor_pane.row == 1, "couldn't set current pane after unrolling")
    stop_editing(pane)  -- save any edits before we blow it away
    Surface[Cursor_pane.col] = column
  else
    table.insert(Surface, Cursor_pane.col+1, column)
    Cursor_pane.col = Cursor_pane.col+1
    Cursor_pane.row = 1
  end
  bring_cursor_of_cursor_pane_in_view('up')
  plan_draw()
end

function populate_unroll_column(column, id, rel)
  if Opposite[rel] == rel then
    add_error(("link type %s is undirected and can't be unrolled"):format(rel))
    return
  end
  if array.find(Non_unique_links, rel) then
    add_error(("link type %s is not unique and can't be unrolled"):format(rel))
    return
  end
  -- back out to start of chain
  while true do
    if Links[id] == nil then
      Links[id] = load_links(id)
    end
    if Links[id][Opposite[rel]] == nil then
      break
    end
    id = Links[id][Opposite[rel]]
  end
  -- unroll from start
  local curr = id
  local n=0
  while curr do
    if Links[curr] == nil then
      Links[curr] = load_links(curr)
    end
    curr = Links[curr][rel]
    n = n+1
  end
  curr = id
  local i=1
  while curr do
    local pane = load_pane(curr)
    add_title(pane, ('%d/%d'):format(i, n))
    table.insert(column, pane)
    curr = Links[curr][rel]
    i = i+1
  end
end

function command.append_note(rel)
  if rel == nil then
    rel = 'next'
  end
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if array.find(Non_unique_links, rel) then
    add_error(('%s is non-unique; which direction should I append?'):format(rel))
    return
  end
  local curr_id = pane.id
  while true do
    if Links[curr_id] == nil then
      Links[curr_id] = load_links(curr_id)
    end
    local next_id = Links[curr_id][rel]
    if next_id == nil then
      break
    end
    curr_id = next_id
  end
  local new_pane = new_pane()
  stop_editing_all()
  new_pane.editable = true
  Links[curr_id][rel] = new_pane.id
  Links[new_pane.id][Opposite[rel]] = curr_id
  if #Surface[Cursor_pane.col] == 1 then
    -- column has a single note; turn it into unroll
    Surface[Cursor_pane.col] = {name=('%s from %s'):format(rel, pane.id)}
    populate_unroll_column(Surface[Cursor_pane.col], pane.id, rel)  -- invalidates new_pane
    Cursor_pane.row = #Surface[Cursor_pane.col]
    Surface[Cursor_pane.col][Cursor_pane.row].editable = true
    bring_cursor_of_cursor_pane_in_view('down')
  elseif string.match(Surface[Cursor_pane.col].name, rel..' from %S+') then
    -- we're unrolling along the same rel; just append to it
    -- (we couldn't be inserting in the middle if we didn't return earlier in
    -- the function)
    table.insert(Surface[Cursor_pane.col], new_pane)
    Cursor_pane.row = #Surface[Cursor_pane.col]
    add_title(new_pane, ('%d/%d'):format(Cursor_pane.row, Cursor_pane.row))
    refresh_pane_height(pane)  -- just in case this is the first link
    bring_cursor_of_cursor_pane_in_view('down')
  else
    local column = {name=new_pane.id}
    table.insert(column, new_pane)
    add_column_to_right_of_cursor(column)
    bring_cursor_of_cursor_pane_in_view('up')
  end
  plan_draw()
end

function command.neighbors()
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  stop_editing(pane)  -- save any edits before we blow it away
  local column = {name=('neighbors of %s'):format(pane.id)}
  populate_neighbors_column(column, pane.id)
  if #Surface[Cursor_pane.col] == 1 then
    assert(Cursor_pane.row == 1, "couldn't repurpose column for neighbors")
    Surface[Cursor_pane.col] = column
  else
    table.insert(Surface, Cursor_pane.col+1, column)
    Cursor_pane.col = Cursor_pane.col+1
    Cursor_pane.row = 1
  end
  bring_cursor_of_cursor_pane_in_view('up')
  plan_draw()
end

function populate_neighbors_column(column, start_id)
  table.insert(column, load_pane(start_id))
  for rel,x in pairs(Links[start_id]) do
    process_all_links(x, function(target)
      local pane = load_pane(target)
      add_title(pane, rel)
      table.insert(column, pane)
    end)
  end
end

-- links might contain either a single target or a list of them
function process_all_links(x, fn)
  if type(x) == 'string' then
    fn(x)
  else
    for _,target in ipairs(x) do
      fn(target)
    end
  end
end

function command.delete_note()
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  if #Links[pane.id] > 0 then
    add_error('pane has links; giving up')
    return
  end
  -- TODO: test harness support for file ops below
  if App.run_tests then
    return
  end
  -- delete from disk
  App.remove(Directory..pane.id)
  -- delete from recently modified
  local filenames = {}
  local f = App.open_for_reading(Directory..'recent')
  for line in f:lines() do
    if line ~= pane.id then
      table.insert(filenames, line)
    end
  end
  f:close()
  local f = App.open_for_writing(Directory..'recent')
  for _,filename in ipairs(filenames) do
    f:write(filename)
    f:write('\n')
  end
  f:close()
  -- Delete any columns dedicated to just this note, and update cursor pane if necessary.
  local delete_cursor_column = Surface[Cursor_pane.col].name == pane.id
  for i=#Surface,Cursor_pane.col+1,-1 do
    local column = Surface[i]
    if column.name == pane.id then
      table.remove(Surface, i)
    end
  end
  local num_deleted = 0
  for i=Cursor_pane.col,1,-1 do
    local column = Surface[i]
    if column.name == pane.id then
      table.remove(Surface, i)
      num_deleted = num_deleted+1
    end
  end
  if num_deleted > 0 then
    Cursor_pane.col = Cursor_pane.col - num_deleted
    if delete_cursor_column then
      Cursor_pane.row = 1
    end
  else
    assert(not delete_cursor_column, "failed to delete note's column")
  end
  --
  while Cursor_pane.row > #Surface[Cursor_pane.col] do
    Cursor_pane.row = #Surface[Cursor_pane.col]
    if Cursor_pane.row == 0 then
      Cursor_pane.col = Cursor_pane.col - 1
      Cursor_pane.row = 1
    end
  end
  --
  command.reload_all()
end

function command.snapshot_memory()
  -- load library on demand
  if mri == nil then
    mri = require('MemoryReferenceInfo')
  end
  collectgarbage('collect')
  print(collectgarbage('count'))
  mri.m_cMethods.DumpMemorySnapshot('./', 'mem', -1)
  add_error('snapshot successfully dumped')
end

function command.send_key_to_current_pane(chord, key)
  if Cursor_pane.col < 1 then
    add_error('no current note')
    return
  end
  local pane = Surface[Cursor_pane.col][Cursor_pane.row]
  if pane == nil then
    add_error('no current note')
    return
  end
  edit.keychord_press(pane, chord, key)
end

-- return a new pane with a unique filename
function new_pane()
  local t = os.time()
  local id = os.date('%Y/%m/%d/%H-%M-%S', t)
  print_and_log('new_pane: creating directory '..Directory..dirname(id))
  local status = App.mkdir(Directory..dirname(id))
  assert(status, "failed to create directory for note")
  Links[id] = {}
  local pane = load_pane(id)
  if not file_exists(pane.filename) then
    table.insert(pane.lines, 1, {data=os.date('%Y-%m-%d %H:%M:%S %Z', t), mode='text'})
    pane.cursor1 = {line=2, pos=1}
    Text.redraw_all(pane)
  end
  return pane
end

function add_column_to_right_of_cursor(column)
  table.insert(Surface, Cursor_pane.col+1, column)
  Cursor_pane.col = Cursor_pane.col+1
  Cursor_pane.row = 1
  bring_cursor_of_cursor_pane_in_view('up')
end

function emit_links_in_json_in_consistent_order(outfile, links)
  local first_written = false
  outfile:write('{')
  for _,label in pairs(Edge_list) do
    if links[label] then
      if first_written then
        outfile:write(',')
      else
        first_written = true
      end
      outfile:write(json.encode(label)..':'..json.encode(links[label]))
    end
  end
  -- links we don't know about, just in case
  for rel,target in pairs(links) do
    if Opposite[rel] == nil then
      if first_written then
        outfile:write(',')
      else
        first_written = true
      end
      outfile:write(json.encode(rel)..':'..json.encode(target))
    end
  end
  outfile:write('}')
end

function concat_all(dir, files)
  if dir == '' then return files end
  for i,file in ipairs(files) do
    files[i] = dir..file
  end
  return files
end

function append(arr, b)
  for _,x in ipairs(b) do
    table.insert(arr, x)
  end
end

function split(s)
  local result = {}
  for sub in s:gmatch("%S+") do
     table.insert(result, sub)
  end
  return result
end

function match_all(s, subs)
  for _,sub in ipairs(subs) do
    if s:find(sub, 1, --[[literal pattern]] true) == nil then
      return false
    end
  end
  return true
end

function find_any(s, subs, start)
  local result = nil
  for _,sub in ipairs(subs) do
    local i = s:find(sub, start, --[[literal pattern]] true)
    if i then
      if result == nil then
        result = i
      elseif i < result then
        result = i
      end
    end
  end
  return result
end