-- Copyright 2011-18 Paul Kulchenko, ZeroBrane LLC
-- authors: Luxinia Dev (Eike Decker & Christoph Kubisch)
---------------------------------------------------------

local ide = ide

ide.filetree = {
  projdir = "",
  projdirlist = {},
  projdirpartmap = {},
  projtreeCtrl = nil,
  imglist = ide:CreateImageList("PROJECT",
    "FOLDER", "FOLDER-MAPPED", "FILE-NORMAL", "FILE-NORMAL-START",
    -- "file known" needs to be the last one as dynamic types can be added after it
    "FILE-KNOWN"),
  settings = {extensionignore = {}, startfile = {}, mapped = {}},
  extmap = {},
}
local filetree = ide.filetree
local iscaseinsensitive = wx.wxFileName("A"):SameAs(wx.wxFileName("a"))
local pathsep = GetPathSeparator()
local q = EscapeMagic
local image = { -- the order of ids has to match the order in the ImageList
  DIRECTORY = 0, DIRECTORYMAPPED = 1, FILEOTHER = 2, FILEOTHERSTART = 3,
  FILEKNOWN = 4,
}

local function getIcon(name, isdir)
  local project = ide:GetProject()
  local startfile = GetFullPathIfExists(project, filetree.settings.startfile[project])
  local ext = GetFileExt(name)
  local extmap = ide.filetree.extmap
  local known = extmap[ext] or ide:FindSpec(ext)
  if not extmap[ext] then    
    local bmp = ide:CreateFileIcon(ext) or false
    if bmp then
      extmap[ext] = ide.filetree.imglist:Add(bmp)
    end
  end
  local icon = isdir and image.DIRECTORY or extmap[ext] or image.FILEOTHER
  if startfile and startfile == name then icon = image.FILEOTHERSTART end
  return icon
end

local function treeAddDir(tree,parent_id,rootdir)
  local items = {}
  local item, cookie = tree:GetFirstChild(parent_id)
  while item:IsOk() do
    items[tree:GetItemText(item) .. tree:GetItemImage(item)] = item
    item, cookie = tree:GetNextChild(parent_id, cookie)
  end

  local cache = {}
  local curr
  local files = ide:GetFileList(rootdir)
  local dirmapped = {}
  local projpath = ide:GetProject()
  local ishoisted = tree:IsDirHoisted(parent_id)
  -- if this is a root, but not hoisted folder
  if tree:IsRoot(parent_id) and not ishoisted then
    local mapped = filetree.settings.mapped[projpath] or {}
    table.sort(mapped)
    -- insert into files at the sorted order
    for i, v in ipairs(mapped) do
      table.insert(files, i, v)
      dirmapped[v] = true
    end
  end

  for _, file in ipairs(files) do
    local name, dir = file:match("([^"..pathsep.."]+)("..pathsep.."?)$")
    local isdir = #dir>0
    if isdir or not filetree.settings.extensionignore[GetFileExt(name)] then
      local icon = getIcon(file, isdir)

      -- keep full name for the mapped directories
      if dirmapped[file] then
        name = file:gsub(q(projpath), ""):gsub(pathsep.."$","")
        icon = image.DIRECTORYMAPPED
      end

      local item = items[name .. icon]
      if item then -- existing item
        -- keep deleting items until we find item
        while true do
          local nextitem = (curr
            and tree:GetNextSibling(curr)
            or tree:GetFirstChild(parent_id))
          if not nextitem:IsOk() or name == tree:GetItemText(nextitem) then
            curr = nextitem
            break
          end
          PackageEventHandle("onFiletreeFileRemove", tree, nextitem, tree:GetItemFullName(nextitem))
          tree:Delete(nextitem)
        end
      else -- new item
        curr = (curr
          and tree:InsertItem(parent_id, curr, name, icon)
          or tree:PrependItem(parent_id, name, icon))
        if isdir then tree:SetItemHasChildren(curr, FileDirHasContent(file)) end
        PackageEventHandle("onFiletreeFileAdd", tree, curr, file)
      end
      if curr:IsOk() then cache[iscaseinsensitive and name:lower() or name] = curr end
    end
  end

  -- delete any leftovers (something that exists in the tree, but not on disk)
  while true do
    local nextitem = (curr
      and tree:GetNextSibling(curr)
      or tree:GetFirstChild(parent_id))
    if not nextitem:IsOk() then break end
    PackageEventHandle("onFiletreeFileRemove", tree, nextitem, tree:GetItemFullName(nextitem))
    tree:Delete(nextitem)
  end

  -- cache the mapping from names to tree items
  if ide.wxver >= "2.9.5" then
    local data = wx.wxLuaTreeItemData()
    data:SetData(cache)
    tree:SetItemData(parent_id, data)
  end

  tree:SetItemHasChildren(parent_id,
    tree:GetChildrenCount(parent_id, false) > 0)

  PackageEventHandle("onFiletreeFileRefresh", tree, parent_id, tree:GetItemFullName(parent_id))
end

local function treeSetRoot(tree,rootdir)
  if not ide:IsValidCtrl(tree) then return end
  if not wx.wxDirExists(rootdir) then return end
  tree:DeleteAllItems()

  local root_id = tree:AddRoot(rootdir, image.DIRECTORY)
  tree:SetItemHasChildren(root_id, true) -- make sure that the item can expand
  tree:Expand(root_id) -- this will also populate the tree

  -- sync with the current editor window and activate selected file
  local editor = ide:GetEditor()
  if editor then FileTreeMarkSelected(ide:GetDocument(editor):GetFilePath()) end
end

local function findItem(tree, match)
  local node = tree:GetRootItem()
  local label = tree:GetItemText(node)

  local s, e
  if iscaseinsensitive then
    s, e = string.find(match:lower(), label:lower(), 1, true)
  else
    s, e = string.find(match, label, 1, true)
  end
  if not s or s ~= 1 then return end

  for token in string.gmatch(string.sub(match,e+1), "[^%"..pathsep.."]+") do
    local data = tree:GetItemData(node)
    local cache = data and data:GetData()
    if cache and cache[iscaseinsensitive and token:lower() or token] then
      node = cache[iscaseinsensitive and token:lower() or token]
    else
      -- token is missing; may need to re-scan the folder; maybe new file
      local dir = tree:GetItemFullName(node)
      treeAddDir(tree,node,dir)

      local item, cookie = tree:GetFirstChild(node)
      while true do
        if not item:IsOk() then return end -- not found
        if tree:GetItemText(item) == token then
          node = item
          break
        end
        item, cookie = tree:GetNextChild(node, cookie)
      end
    end
  end

  -- this loop exits only when a match is found
  return node
end

local function treeSetConnectorsAndIcons(tree)
  tree:AssignImageList(filetree.imglist)

  local function isIt(item, imgtype) return tree:GetItemImage(item) == imgtype end

  function tree:IsDirectory(item_id) return isIt(item_id, image.DIRECTORY) end
  function tree:IsDirMapped(item_id) return isIt(item_id, image.DIRECTORYMAPPED) end
  function tree:IsDirHoisted(item_id)
    return (self:IsRoot(item_id) and
      not ide:IsSameDirectoryPath(ide:GetProject(), self:GetItemFullName(item_id)))
  end
  -- "file known" is a special case as it includes file types registered dynamically
  function tree:IsFileKnown(item_id) return tree:GetItemImage(item_id) >= image.FILEKNOWN end
  function tree:IsFileOther(item_id) return isIt(item_id, image.FILEOTHER) end
  function tree:IsFileStart(item_id) return isIt(item_id, image.FILEOTHERSTART) end
  function tree:IsRoot(item_id) return not tree:GetItemParent(item_id):IsOk() end

  function tree:GetFileImage(name) return getIcon(name, false) end

  local function saveSettings()
    ide:GetPackage('core.filetree'):SetSettings(filetree.settings)
  end

  function tree:FindItem(match)
    return findItem(self, (wx.wxIsAbsolutePath(match) or match == '') and match
      or MergeFullPath(ide:GetProject(), match))
  end

  function tree:GetItemFullName(item_id)
    local tree = self
    local str = tree:GetItemText(item_id)
    local cur = str

    while (#cur > 0) do
      item_id = tree:GetItemParent(item_id)
      if not item_id:IsOk() then break end
      cur = tree:GetItemText(item_id)
      if cur and #cur > 0 then str = MergeFullPath(cur, str) end
    end
    -- as root may already include path separator, normalize the path
    return FileNormalizePath(str)
  end

  function tree:RefreshChildren(node)
    node = node or tree:GetRootItem()
    treeAddDir(tree,node,tree:GetItemFullName(node))
    local item, cookie = tree:GetFirstChild(node)
    while true do
      if not item:IsOk() then return end
      if tree:IsExpanded(item) then tree:RefreshChildren(item) end
      item, cookie = tree:GetNextChild(node, cookie)
    end
  end

  local function refreshAncestors(node)
    -- when this method is called from END_EDIT, it causes infinite loop
    -- on OSX (wxwidgets 2.9.5) as Delete in treeAddDir calls END_EDIT again.
    -- disable handlers while the tree is populated and then enable back.
    tree:SetEvtHandlerEnabled(false)
    while node:IsOk() do
      local dir = tree:GetItemFullName(node)
      treeAddDir(tree,node,dir)
      node = tree:GetItemParent(node)
    end
    tree:SetEvtHandlerEnabled(true)
  end

  function tree:ActivateItem(item_id)
    local name = tree:GetItemFullName(item_id)

    local event = wx.wxTreeEvent(wx.wxEVT_COMMAND_TREE_ITEM_ACTIVATED, item_id:GetValue())
    if PackageEventHandle("onFiletreeActivate", tree, event, item_id) == false then
      return
    end

    -- refresh the folder
    if (tree:IsDirectory(item_id) or tree:IsDirMapped(item_id)) then
      if wx.wxDirExists(name) then
        treeAddDir(tree,item_id,name)
        tree:Toggle(item_id)
      else refreshAncestors(tree:GetItemParent(item_id)) end -- stale content
    else -- open file
      -- delay activation to keep the focus on the editor,
      -- as doubleclick sends it back to the tree on Windows.
      if wx.wxFileExists(name) then ide:DoWhenIdle(function() LoadFile(name,nil,true) end)
      else refreshAncestors(tree:GetItemParent(item_id)) end -- stale content
    end
  end

  function tree:UnmapDirectory(path)
    local project = ide:GetProject()
    if not project then return nil, "Project is not set" end

    local dir = wx.wxFileName.DirName(ide:MergePath(project, path))
    local mapped = filetree.settings.mapped[project] or {}
    for k = #mapped,1,-1 do
      if dir:SameAs(wx.wxFileName.DirName(mapped[k])) then table.remove(mapped, k) end
    end
    filetree.settings.mapped[project] = #mapped > 0 and mapped or nil
    saveSettings()
    refreshAncestors(self:GetRootItem())
    return true
  end
  function tree:MapDirectory(path)
    local project = ide:GetProject()
    if not project then return nil, "Project is not set" end

    if not path then
      local dirPicker = wx.wxDirDialog(ide.frame, TR("Choose a directory to map"),
        project ~= "" and project or wx.wxGetCwd(), wx.wxDIRP_DIR_MUST_EXIST)
      if dirPicker:ShowModal(true) ~= wx.wxID_OK then return end
      local dir = wx.wxFileName.DirName(FixDir(dirPicker:GetPath()))
      -- don't remap the project directory
      if dir:SameAs(wx.wxFileName.DirName(project)) then return end
      path = dir:GetFullPath()
    end

    local dir = wx.wxFileName.DirName(ide:MergePath(project, path))
    if not dir:DirExists() then return nil, "Directory doesn't exist" end

    local mapped = filetree.settings.mapped[project] or {}
    for _, m in ipairs(mapped) do
      if dir:SameAs(wx.wxFileName.DirName(m)) then return end -- already on the list
    end
    -- add to the list; the path includes trailing separator used for directory detection
    table.insert(mapped, dir:GetFullPath())
    filetree.settings.mapped[project] = mapped
    saveSettings()
    refreshAncestors(self:GetRootItem())
    return true
  end

  local empty = ""
  local function renameItem(itemsrc, target)
    local isdir = tree:IsDirectory(itemsrc)
    local isnew = tree:GetItemText(itemsrc) == empty
    local source = tree:GetItemFullName(itemsrc)

    -- check if the target is the same as the source;
    -- SameAs check is not used here as "Test" and "test" are the same
    -- on case insensitive systems, but need to be allowed in renaming.
    if source == target then return end

    -- if target is a file, is already loaded and modified, then reject renaming
    local targetdocs = isnew and {} or (isdir
      and ide:FindDocumentsByPartialPath(target)
      or {ide:FindDocument(target)})
    for _, doc in ipairs(targetdocs) do
      if doc and doc:IsModified() then
        ide:ReportError(TR("Can't overwrite unsaved file '%s'."):format(doc:GetFilePath()))
        return false
      end
    end

    if PackageEventHandle("onFiletreeFilePreRename", tree, itemsrc, source, target) == false then
      return false
    end

    -- check if existing file/dir is going to be overwritten
    local overwrite = ((wx.wxFileExists(target) or wx.wxDirExists(target))
      and not wx.wxFileName(source):SameAs(fn))
    local doc = ide:FindDocument(target)
    if overwrite and doc then doc:SetActive() end
    if overwrite and not ApproveFileOverwrite() then return false end

    if not wx.wxFileName(target):Mkdir(tonumber("755",8), wx.wxPATH_MKDIR_FULL) then
      ide:ReportError(TR("Unable to create directory '%s'."):format(target))
      return false
    end

    if isnew then -- new directory or file; create manually
      if (isdir and not wx.wxFileName.DirName(target):Mkdir(tonumber("755",8), wx.wxPATH_MKDIR_FULL))
      or (not isdir and not FileWrite(target, "")) then
        ide:ReportError(TR("Unable to create file '%s'."):format(target))
        return false
      end
    else -- existing directory or file; rename/move it
      local ok, err = FileRename(source, target)
      if not ok then
        ide:ReportError(TR("Unable to rename file '%s'."):format(source)
          .."\nError: "..err)
        return false
      end
    end

    local expanded = tree:IsExpanded(itemsrc)
    local pos = tree:GetScrollPos(wx.wxVERTICAL)

    tree:Freeze()

    refreshAncestors(tree:GetItemParent(itemsrc))

    -- if not new, check if source is already opened in the editor
    local sourcedocs = isnew and {} or (isdir
      and ide:FindDocumentsByPartialPath(source)
      or {ide:FindDocument(source)})
    local targetdoc = ide:FindDocument(target)

    -- need to protect against `%` characters in target, as those can be interpteted
    -- as a sequence number in replacement
    local escapedtarget = target:gsub("%%", "%%%%")
    for _, doc in ipairs(sourcedocs) do
      local fullpath = doc:GetFilePath()
      -- when moving folders, /foo/bar/file.lua can be replaced with
      -- /foo/baz/bar/file.lua, so change /foo/bar to /foo/baz/bar
      local path = (not iscaseinsensitive and fullpath:gsub(q(source), escapedtarget)
        or fullpath:lower():gsub(q(source:lower()), escapedtarget))

      doc:SetFilePath(path)
      doc:SetFileName(wx.wxFileName(path):GetFullName())
      doc:SetFileModifiedTime(GetFileModTime(path))
      doc:SetTabText(doc:GetFileName())
      if doc:IsActive() then doc:SetActive() end
      -- reset target document if it matches one of the source docs
      -- this may happen if one of the opened documents is renamed
      if doc == targetdoc then targetdoc = nil end
    end

    -- close the target document, since the source has already been updated for it
    if targetdoc and #sourcedocs > 0 then targetdoc:Close() end

    tree:Thaw()

    local itemdst = tree:FindItem(target)
    if itemdst then
      tree:UnselectAll()
      tree:RefreshChildren(tree:GetItemParent(itemdst))
      tree:SetFocusedItem(itemdst)
      tree:SelectItem(itemdst)
      if expanded then tree:Expand(itemdst) end
      tree:SetScrollPos(wx.wxVERTICAL, pos)
    end

    PackageEventHandle("onFiletreeFileRename", tree, itemsrc, source, target)

    return true
  end
  local function deleteItem(item_id)
    -- if delete is for mapped directory, unmap it instead
    if tree:IsDirMapped(item_id) then
      tree:UnmapDirectory(tree:GetItemText(item_id))
      return
    end

    local isdir = tree:IsDirectory(item_id)
    local source = tree:GetItemFullName(item_id)

    if PackageEventHandle("onFiletreeFilePreDelete", tree, item_id, source) == false then
      return false
    end

    if isdir and FileDirHasContent(source..pathsep) then return false end
    if wx.wxMessageBox(
      TR("Do you want to delete '%s'?"):format(source),
      ide:GetProperty("editormessage"),
      wx.wxYES_NO + wx.wxCENTRE, ide.frame) ~= wx.wxYES then return false end

    if isdir then
      if not wx.wxRmdir(source) then
        ide:ReportError(TR("Unable to delete directory '%s': %s")
          :format(source, wx.wxSysErrorMsg()))
      end
    else
      local doc = ide:FindDocument(source)
      if doc then doc:Close() end
      if not wx.wxRemoveFile(source) then
        ide:ReportError(TR("Unable to delete file '%s': %s")
          :format(source, wx.wxSysErrorMsg()))
      end
    end
    refreshAncestors(tree:GetItemParent(item_id))
    PackageEventHandle("onFiletreeFileDelete", tree, item_id, source)
    return true
  end

  if wx.wxLuaFileDropTarget then
    -- localizing FileDropTarget doesn't work in wxlua 2.8.13, so keep it in a field
    ide.filetree.filedrop = wx.wxLuaFileDropTarget()
    ide.filetree.filedrop.OnDropFiles = function(self, x, y, filenames)
      local item_id = tree:HitTest(wx.wxPoint(x,y))
      -- set project if one file moved over the project directory
      if item_id:IsOk() and not tree:GetItemParent(item_id):IsOk() and #filenames == 1 then
        ide:SetProject(filenames[1])
      else
        for i = 1, #filenames do tree:MapDirectory(filenames[i]) end
      end
      return true
    end
    tree:SetDropTarget(ide.filetree.filedrop)
  end

  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_COLLAPSED,
    function (event)
      PackageEventHandle("onFiletreeCollapse", tree, event, event:GetItem())
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_EXPANDED,
    function (event)
      PackageEventHandle("onFiletreeExpand", tree, event, event:GetItem())
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_COLLAPSING,
    function (event)
      if PackageEventHandle("onFiletreePreCollapse", tree, event, event:GetItem()) == false then
        return
      end
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_EXPANDING,
    function (event)
      local item_id = event:GetItem()
      if PackageEventHandle("onFiletreePreExpand", tree, event, item_id) == false then
        return
      end

      local dir = tree:GetItemFullName(item_id)
      if wx.wxDirExists(dir) then treeAddDir(tree,item_id,dir) -- refresh folder
      else refreshAncestors(tree:GetItemParent(item_id)) end -- stale content
      return true
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_ACTIVATED,
    function (event)
      tree:ActivateItem(event:GetItem())
    end)

  -- refresh the tree
  local function refreshChildren()
    tree:RefreshChildren()
    -- now mark the current file (if it was previously disabled)
    local editor = ide:GetEditor()
    if editor then FileTreeMarkSelected(ide:GetDocument(editor):GetFilePath()) end
  end

  -- handle context menu
  local function addItem(item_id, name, img)
    local isdir = tree:IsDirectory(item_id)
    local ismapped = tree:IsDirMapped(item_id)
    local parent = (isdir or ismapped) and item_id or tree:GetItemParent(item_id)
    if isdir or ismapped then tree:Expand(item_id) end -- expand to populate if needed

    local item = tree:PrependItem(parent, name, img)
    tree:SetItemHasChildren(parent, true)
    -- temporarily disable expand as we don't need this node populated
    if not tree:IsVisible(item) then
      tree:SetEvtHandlerEnabled(false)
      tree:EnsureVisible(item)
      tree:SetEvtHandlerEnabled(true)
    end
    return item
  end

  local function updateItemImage(item_id)
    if item_id and item_id:IsOk() then
      local path = tree:GetItemFullName(item_id)
      tree:SetItemImage(item_id, getIcon(path))
      -- if the file is opened, then change the tab icon as well
      local doc = ide:FindDocument(path)
      if doc then doc:SetTabText() end
    end
  end

  local function unsetStartFile()
    local project = ide:GetProject()
    if not project then return end

    local startfile = filetree.settings.startfile[project]
    filetree.settings.startfile[project] = nil
    if startfile then
      updateItemImage(tree:FindItem(startfile))
    end
    return startfile
  end

  local function setStartFile(item_id)
    local project = ide:GetProject()
    if not project then return end

    local startfile = tree:GetItemFullName(item_id):gsub(q(project), "")
    filetree.settings.startfile[project] = startfile
    updateItemImage(item_id)
    return startfile
  end

  function tree:GetStartFile()
    local project = ide:GetProject()
    return project and filetree.settings.startfile[project]
  end

  function tree:SetStartFile(path)
    local item_id
    local project = ide:GetProject()
    if project and type(path) == "string" then
      local startfile = path:gsub(q(project), "")
      item_id = self:FindItem(startfile)
    end
    -- unset if explicitly requested or the replacement has been found
    if not path or item_id then unsetStartFile() end
    if item_id then setStartFile(item_id) end

    saveSettings()
    return item_id
  end

  if ide.osname == "Unix" then
    local gfi = tree.GetFocusedItem
    tree.focusedItem = false
    function tree:GetFocusedItem(...) return self.focusedItem or gfi(self, ...) end
  end

  tree:Connect(ID.NEWFILE, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:EditLabel(addItem(tree:GetFocusedItem(), empty, image.FILEOTHER))
    end)
  tree:Connect(ID.NEWDIRECTORY, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:EditLabel(addItem(tree:GetFocusedItem(), empty, image.DIRECTORY))
    end)
  tree:Connect(ID.RENAMEFILE, wx.wxEVT_COMMAND_MENU_SELECTED,
    function() tree:EditLabel(tree:GetFocusedItem()) end)
  tree:Connect(ID.DELETEFILE, wx.wxEVT_COMMAND_MENU_SELECTED,
    function() deleteItem(tree:GetFocusedItem()) end)
  tree:Connect(ID.COPYFULLPATH, wx.wxEVT_COMMAND_MENU_SELECTED,
    function() ide:CopyToClipboard(tree:GetItemFullName(tree:GetFocusedItem())) end)
  tree:Connect(ID.OPENEXTENSION, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      local fname = tree:GetItemFullName(tree:GetFocusedItem())
      local ext = '.'..wx.wxFileName(fname):GetExt()
      local ft = wx.wxTheMimeTypesManager:GetFileTypeFromExtension(ext)
      if ft then
        local cmd = ft:GetOpenCommand(fname:gsub('"','\\"'))
        -- some programs on Windows, when started by rundll32.exe (for example, PhotoViewer)
        -- accept files with spaces in names ONLY if they are not in quotes.
        if ide.osname == "Windows" and cmd:find("rundll32%.exe") then
          cmd = ft:GetOpenCommand(""):gsub('""%s*$', '')..fname
        end
        wx.wxExecute(cmd, wx.wxEXEC_ASYNC)
      end
    end)
  tree:Connect(ID.REFRESH, wx.wxEVT_COMMAND_MENU_SELECTED,
    function() refreshChildren() end)
  tree:Connect(ID.SHOWLOCATION, wx.wxEVT_COMMAND_MENU_SELECTED,
    function() ShowLocation(tree:GetItemFullName(tree:GetFocusedItem())) end)
  tree:Connect(ID.HIDEEXTENSION, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      local ext = GetFileExt(tree:GetItemText(tree:GetFocusedItem()))
      filetree.settings.extensionignore[ext] = true
      saveSettings()
      refreshChildren()
    end)
  tree:Connect(ID.SHOWEXTENSIONALL, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      filetree.settings.extensionignore = {}
      saveSettings()
      refreshChildren()
    end)
  tree:Connect(ID.SETSTARTFILE, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:SetStartFile(tree:GetItemFullName(tree:GetFocusedItem()))
    end)
  tree:Connect(ID.UNSETSTARTFILE, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:SetStartFile()
    end)
  tree:Connect(ID.MAPDIRECTORY, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:MapDirectory()
    end)
  tree:Connect(ID.UNMAPDIRECTORY, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      tree:UnmapDirectory(tree:GetItemText(tree:GetFocusedItem()))
    end)
  tree:Connect(ID.HOISTDIRECTORY, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      treeSetRoot(tree, tree:GetItemFullName(tree:GetFocusedItem()))
    end)
  tree:Connect(ID.UNHOISTDIRECTORY, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      treeSetRoot(tree, ide:GetProject())
    end)
  tree:Connect(ID.PROJECTDIRFROMDIR, wx.wxEVT_COMMAND_MENU_SELECTED,
    function()
      ide:SetProject(tree:GetItemFullName(tree:GetFocusedItem()))
    end)

  tree:Connect(wx.wxEVT_COMMAND_TREE_ITEM_MENU,
    function (event)
      local item_id = event:GetItem()
      tree:SetFocusedItem(item_id)

      local renamelabel = (tree:IsRoot(item_id)
        and TR("&Edit Project Directory")
        or TR("&Rename"))
      local fname = tree:GetItemText(item_id)
      local ext = GetFileExt(fname)
      local project = ide:GetProject()
      local startfile = project and filetree.settings.startfile[project]
      local menu = ide:MakeMenu {
        { ID.NEWFILE, TR("New &File") },
        { ID.NEWDIRECTORY, TR("&New Directory") },
        { },
        { ID.RENAMEFILE, renamelabel..KSC(ID.RENAMEFILE) },
        { ID.DELETEFILE, TR("&Delete")..KSC(ID.DELETEFILE) },
        { ID.REFRESH, TR("Refresh") },
        { },
        { ID.HIDEEXTENSION, TR("Hide '.%s' Files"):format(ext) },
        { },
        { ID.SETSTARTFILE, TR("Set As Start File") },
        { ID.UNSETSTARTFILE, TR("Unset '%s' As Start File"):format(startfile or "<none>") },
        { },
        { ID.HOISTDIRECTORY, TR("Hoist Directory") },
        { ID.UNHOISTDIRECTORY, TR("Unhoist Directory") },
        { ID.MAPDIRECTORY, TR("Map Directory...") },
        { ID.UNMAPDIRECTORY, TR("Unmap Directory") },
        { ID.OPENEXTENSION, TR("Open With Default Program") },
        { ID.COPYFULLPATH, TR("Copy Full Path") },
        { ID.SHOWLOCATION, TR("Open Containing Folder") },
      }
      local extlist = {
        {},
        { ID.SHOWEXTENSIONALL, TR("Show All Files"), TR("Show all files") },
      }
      for extignore in pairs(filetree.settings.extensionignore) do
        local id = ID("filetree.showextension."..extignore)
        table.insert(extlist, 1, {id, '.'..extignore})
        menu:Connect(id, wx.wxEVT_COMMAND_MENU_SELECTED, function()
          filetree.settings.extensionignore[extignore] = nil
          saveSettings()
          refreshChildren()
        end)
      end
      local _, _, hideextpos = ide:FindMenuItem(ID.HIDEEXTENSION, menu)
      assert(hideextpos, "Can't find HideExtension menu item")
      menu:Insert(hideextpos+1, wx.wxMenuItem(menu, ID.SHOWEXTENSION,
        TR("Show Hidden Files"), TR("Show files previously hidden"),
        wx.wxITEM_NORMAL, ide:MakeMenu(extlist)))

      local projectdirectorymenu = ide:MakeMenu {
        { },
        {ID.PROJECTDIRCHOOSE, TR("Choose...")..KSC(ID.PROJECTDIRCHOOSE), TR("Choose a project directory")},
        {ID.PROJECTDIRFROMDIR, TR("Set To Selected Directory")..KSC(ID.PROJECTDIRFROMDIR), TR("Set project directory to the selected one")},
      }
      local projectdirectory = wx.wxMenuItem(menu, ID.PROJECTDIR,
        TR("Project Directory"), TR("Set the project directory to be used"),
        wx.wxITEM_NORMAL, projectdirectorymenu)
      local _, _, unmapdirpos = ide:FindMenuItem(ID.UNMAPDIRECTORY, menu)
      assert(unmapdirpos, "Can't find UnMapDirectory menu item")
      menu:Insert(unmapdirpos+1, projectdirectory)
      FileTreeProjectListUpdate(projectdirectorymenu, 0)

      -- disable Delete on non-empty directories
      local isdir = tree:IsDirectory(item_id)
      local ismapped = tree:IsDirMapped(item_id)
      menu:Destroy(ismapped and ID.MAPDIRECTORY or ID.UNMAPDIRECTORY)

      local isroot = tree:IsRoot(item_id)
      local ishoisted = tree:IsDirHoisted(tree:GetRootItem())
      menu:Enable(ID.UNHOISTDIRECTORY, ishoisted)
      menu:Enable(ID.HOISTDIRECTORY, isdir and not isroot or ismapped)
      if not ishoisted then menu:Destroy(ID.UNHOISTDIRECTORY) end
      if ishoisted and (not isdir or isroot) then menu:Destroy(ID.HOISTDIRECTORY) end

      if not startfile then menu:Destroy(ID.UNSETSTARTFILE) end
      if ismapped then menu:Enable(ID.RENAMEFILE, false) end
      if isdir then
        local source = tree:GetItemFullName(item_id)
        menu:Enable(ID.DELETEFILE, not FileDirHasContent(source..pathsep))
        menu:Enable(ID.OPENEXTENSION, false)
        menu:Enable(ID.HIDEEXTENSION, false)
      else
        local ft = wx.wxTheMimeTypesManager:GetFileTypeFromExtension('.'..ext)
        menu:Enable(ID.OPENEXTENSION, ft and #ft:GetOpenCommand("") > 0)
        menu:Enable(ID.HIDEEXTENSION, not filetree.settings.extensionignore[ext])
        menu:Enable(ID.PROJECTDIRFROMDIR, false)
      end
      menu:Enable(ID.SETSTARTFILE, tree:IsFileOther(item_id) or tree:IsFileKnown(item_id))
      menu:Enable(ID.SHOWEXTENSION, next(filetree.settings.extensionignore) ~= nil)

      PackageEventHandle("onMenuFiletree", menu, tree, event)

      -- stopping/restarting garbage collection is generally not needed,
      -- but on Linux not stopping is causing crashes (wxwidgets 2.9.5 and 3.1.0)
      -- when symbol indexing is done while popup menu is open (with gc methods in the trace).
      -- this only happens when EVT_IDLE is called when popup menu is open.
      collectgarbage("stop")

      -- stopping UI updates is generally not needed as well,
      -- but it's causing a crash on OSX (wxwidgets 2.9.5 and 3.1.0)
      -- when symbol indexing is done while popup menu is open, so it's disabled
      local interval = wx.wxUpdateUIEvent.GetUpdateInterval()
      wx.wxUpdateUIEvent.SetUpdateInterval(-1) -- don't update

      if ide.osname == "Unix" then
        -- Linux implementation is losing focus item state after the popup window is shown,
        -- so save it to use if needed
        tree.focusedItem = tree:GetFocusedItem()
      end

      tree:PopupMenu(menu)

      tree.focusedItem = false
      wx.wxUpdateUIEvent.SetUpdateInterval(interval)
      collectgarbage("restart")
    end)

  tree:Connect(wx.wxEVT_RIGHT_DOWN,
    function (event)
      local item_id = tree:HitTest(event:GetPosition())
      if PackageEventHandle("onFiletreeRDown", tree, event, item_id and item_id:IsOk() and item_id or nil) == false then
        return
      end
      event:Skip()
    end)

  -- toggle a folder on a single click
  tree:Connect(wx.wxEVT_LEFT_DOWN,
    function (event)
      -- only toggle if this is a folder and the click is on the item line
      -- (exclude the label as it's used for renaming and dragging)
      local mask = (wx.wxTREE_HITTEST_ONITEMINDENT
        + wx.wxTREE_HITTEST_ONITEMICON + wx.wxTREE_HITTEST_ONITEMRIGHT)
      local item_id, flags = tree:HitTest(event:GetPosition())

      if PackageEventHandle("onFiletreeLDown", tree, event, item_id and item_id:IsOk() and item_id or nil) == false then
        return
      end

      if item_id and item_id:IsOk() and bit.band(flags, mask) > 0 then
        if tree:IsDirectory(item_id) then
          tree:Toggle(item_id)
        else
          local name = tree:GetItemFullName(item_id)
          if wx.wxFileExists(name) then LoadFile(name,nil,true) end
        end
      else
        event:Skip()
      end
      return true
    end)
  local parent
  tree:Connect(wx.wxEVT_COMMAND_TREE_BEGIN_LABEL_EDIT,
    function (event)
      local itemsrc = event:GetItem()
      parent = tree:GetItemParent(itemsrc)
      if not itemsrc:IsOk() or tree:IsDirMapped(itemsrc) then event:Veto() end
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_END_LABEL_EDIT,
    function (event)
      -- veto the event to keep the original label intact as the tree
      -- is going to be refreshed with the correct names.
      event:Veto()

      local itemsrc = event:GetItem()
      if not itemsrc:IsOk() then return end

      local label = event:GetLabel():gsub("^%s+$","") -- clean all spaces

      -- edited the root element; set the new project directory if needed
      local cancelled = event:IsEditCancelled()
      if tree:IsRoot(itemsrc) then
        if not cancelled and wx.wxDirExists(label) then
          ide:SetProject(label)
        end
        return
      end

      if not parent or not parent:IsOk() then return end
      local target = MergeFullPath(tree:GetItemFullName(parent), label)
      if cancelled or label == empty then refreshAncestors(parent)
      elseif target then
        -- wxwidgets v2.9.5 and earlier crashes when the IDE loses focus
        -- during renaming a file that has a conflict with an exising one
        -- (caused by the same END_LABEL_EDIT even triggered one more time).
        if ide.osname == "Linux" and ide.wxver <= "2.9.5" then return end
        if not renameItem(itemsrc, target) then refreshAncestors(parent) end
      end
    end)

  if ide.wxver >= "3.1.3" then
    -- provide workaround for white background in a focused item in the tree
    -- on Linux when the tree itself is unfocused (may be gtk3-only issue);
    -- enable on all platforms, as the brackground is low contrast on MacOS
    -- and blue-colored on Windows
    tree:Connect(wx.wxEVT_KILL_FOCUS, function(event) tree:ClearFocusedItem() end)
  end

  local itemsrc
  tree:Connect(wx.wxEVT_COMMAND_TREE_BEGIN_DRAG,
    function (event)
      if ide.config.filetree.mousemove and tree:GetItemParent(event:GetItem()):IsOk() then
        itemsrc = event:GetItem()
        event:Allow()
      end
    end)
  tree:Connect(wx.wxEVT_COMMAND_TREE_END_DRAG,
    function (event)
      local itemdst = event:GetItem()
      if not itemdst:IsOk() or not itemsrc:IsOk() then return end

      -- check if itemdst is a folder
      local target = tree:GetItemFullName(itemdst)
      if wx.wxDirExists(target) then
        local source = tree:GetItemFullName(itemsrc)
        -- check if moving the directory and target is a subfolder of source
        if (target..pathsep):find("^"..q(source)..pathsep) then return end
        renameItem(itemsrc, MergeFullPath(target, tree:GetItemText(itemsrc)))
      end
    end)
end

-- project
local projtree = ide:CreateTreeCtrl(ide.frame, wx.wxID_ANY,
  wx.wxDefaultPosition, wx.wxDefaultSize,
  wx.wxTR_HAS_BUTTONS + wx.wxTR_MULTIPLE + wx.wxTR_TWIST_BUTTONS + wx.wxTR_NO_LINES
  + wx.wxTR_EDIT_LABELS + wx.wxNO_BORDER)
projtree:SetFont(ide.font.tree)
filetree.projtreeCtrl = projtree

local bmp = ide:GetBitmap("PROJECT", "TREE", wx.wxSize(16, 16))
ide:GetProjectNotebook():AddPage(projtree, TR("Project"), true, bmp)

-- proj connectors
-- ---------------

treeSetConnectorsAndIcons(projtree)

-- proj functions
-- ---------------

local function appendPathSep(dir)
  return (dir and #dir > 0 and wx.wxFileName.DirName(dir):GetFullPath() or nil)
end

function filetree:updateProjectDir(newdir)
  if (not newdir) or not wx.wxDirExists(newdir) then return nil, "Directory doesn't exist" end
  local dirname = wx.wxFileName.DirName(newdir)

  if filetree.projdir and #filetree.projdir > 0
  and dirname:SameAs(wx.wxFileName.DirName(filetree.projdir)) then return false end

  -- strip the last path separator if any
  local newdir = dirname:GetPath(wx.wxPATH_GET_VOLUME)

  -- save the current interpreter as it may be reset in onProjectClose
  -- when the project event handlers manipulates interpreters
  local intfname = ide.interpreter and ide.interpreter.fname

  if filetree.projdir and #filetree.projdir > 0 then
    PackageEventHandle("onProjectClose", appendPathSep(filetree.projdir))
  end

  PackageEventHandle("onProjectPreLoad", appendPathSep(newdir))

  if ide.config.projectautoopen and filetree.projdir then
    StoreRestoreProjectTabs(filetree.projdir, newdir, intfname)
  end

  filetree.projdir = newdir
  filetree.projdirpartmap = {}

  PrependStringToArray(
    filetree.projdirlist,
    newdir,
    ide.config.projecthistorylength,
    function(s1, s2) return dirname:SameAs(wx.wxFileName.DirName(s2)) end)

  ide:SetProject(newdir,true)
  treeSetRoot(projtree,newdir)

  -- refresh Recent Projects menu item
  ide.frame:AddPendingEvent(wx.wxUpdateUIEvent(ID.RECENTPROJECTS))

  PackageEventHandle("onProjectLoad", appendPathSep(newdir))

  return true
end

function FileTreeSetProjects(tab)
  filetree.projdirlist = tab
  if (tab and tab[1]) then
    filetree:updateProjectDir(tab[1])
  end
end

function FileTreeGetProjects() return filetree.projdirlist end

local function getProjectLabels()
  local labels = {}
  local fmt = ide.config.format.menurecentprojects or '%f'
  for _, proj in ipairs(FileTreeGetProjects()) do
    local config = ide.session.projects[proj]
    local intfname = config and config[2] and config[2].interpreter or ide.interpreter:GetFileName()
    local interpreter = intfname and ide.interpreters[intfname]
    local parts = wx.wxFileName(proj..pathsep):GetDirs()
    table.insert(labels, ide:ExpandPlaceholders(fmt, {
          f = proj,
          i = interpreter and interpreter:GetName() or (intfname or '')..'?',
          s = parts[#parts] or '',
        }))
  end
  return labels
end

function FileTreeProjectListClear()
  -- remove all items from the list except the current one
  filetree.projdirlist = {ide:GetProject()}
end

function FileTreeProjectListUpdate(menu, items)
  -- protect against recent project menu not being present
  if not ide:FindMenuItem(ID.RECENTPROJECTS) then return end

  local list = getProjectLabels()
  for i=#list, 1, -1 do
    local id = ID("file.recentprojects."..i)
    local label = list[i]
    if i <= items then -- this is an existing item; update the label
      menu:FindItem(id):SetItemLabel(label)
    else -- need to add an item
      local item = wx.wxMenuItem(menu, id, label, "")
      menu:Insert(items, item)
      ide.frame:Connect(id, wx.wxEVT_COMMAND_MENU_SELECTED, function()
          wx.wxSafeYield() -- let the menu on screen (if any) disappear
          ide:SetProject(FileTreeGetProjects()[i])
        end)
    end
    -- disable the currently selected project
    if i == 1 then menu:Enable(id, false) end
  end
  for i=items, #list+1, -1 do -- delete the rest if the list got shorter
    menu:Delete(menu:FindItemByPosition(i-1))
  end
  return #list
end

local curr_file
function FileTreeMarkSelected(file)
  if not file or not filetree.projdir or #filetree.projdir == 0
  or not ide:IsValidCtrl(projtree) then return end

  local item_id = wx.wxIsAbsolutePath(file) and projtree:FindItem(file)

  -- if the select item is different from the current one
  -- or the current one is the same, but not bold (which may happen when
  -- the project is changed to one that includes the current item)
  if curr_file ~= file
  or item_id and not projtree:IsBold(item_id) then
    if curr_file then
      local curr_id = wx.wxIsAbsolutePath(curr_file) and projtree:FindItem(curr_file)
      if curr_id and projtree:IsBold(curr_id) then
        projtree:SetItemBold(curr_id, false)
        PackageEventHandle("onFiletreeFileMarkSelected", projtree, curr_id, curr_file, false)
      end
    end
    if item_id then
      if not projtree:IsVisible(item_id) then
        projtree:EnsureVisible(item_id)
        -- it's supposed to be enough to do EnsureVisible,
        -- but occasionally it's positioned one item too high (wxwidgets 3.1.1 on Win),
        -- so scroll to make sure the item really is visible
        projtree:ScrollTo(item_id)
        projtree:SetScrollPos(wx.wxHORIZONTAL, 0, true)
        if ide.osname == "Windows" then
          -- the following is a workaround for SetScrollPos not scrolling in wxwidgets 3.1.3
          -- https://trac.wxwidgets.org/ticket/18543
          projtree:Freeze()
          projtree:Thaw()
        end
      end
      projtree:SetItemBold(item_id, true)
      PackageEventHandle("onFiletreeFileMarkSelected", projtree, item_id, file, true)
    end
    curr_file = file
    if ide.wxver < "2.9.5" and ide.osname == 'Macintosh' then
      projtree:Refresh()
    end
  end
end

function FileTreeFindByPartialName(name)
  -- check if it's already cached
  if filetree.projdirpartmap[name] then return filetree.projdirpartmap[name] end

  -- this function may get a partial name that starts with ... and has
  -- an abbreviated path (as generated by stack traces);
  -- remove starting "..." if any and escape
  local pattern = q(name:gsub("^%.%.%.","")):gsub("[\\/]", "[\\/]").."$"
  local lpattern = pattern:lower()

  for _, file in ipairs(ide:GetFileList(filetree.projdir, true)) do
    if file:find(pattern) or iscaseinsensitive and file:lower():find(lpattern) then
      filetree.projdirpartmap[name] = file
      return file
    end
  end
  return
end

local watchers, blacklist, watcher = {}, {}
local function watchDir(path)
  if watcher and not watchers[path] and not blacklist[path] then
    local _ = wx.wxLogNull() -- disable error reporting; will report as needed
    local ok  = watcher:Add(wx.wxFileName.DirName(path),
      wx.wxFSW_EVENT_CREATE + wx.wxFSW_EVENT_DELETE + wx.wxFSW_EVENT_RENAME)
    if not ok then
      blacklist[path] = true
      ide:GetOutput():Error(("Can't set watch for '%s': %s"):format(path, wx.wxSysErrorMsg()))
      return nil, wx.wxSysErrorMsg()
    end
  end
  -- keep track of watchers even if `watcher` is not yet set to set them later
  watchers[path] = watcher ~= nil
  return watchers[path]
end
local function unWatchDir(path)
  if watcher and watchers[path] then watcher:Remove(wx.wxFileName.DirName(path)) end
  watchers[path] = nil
end

local function syncTree(editor)
    local doc = editor and ide:GetDocument(editor)
    FileTreeMarkSelected(doc and doc:GetFilePath() or '')
    SetAutoRecoveryMark()
    ide:SetTitle()
end

local package = ide:AddPackage('core.filetree', {
    onProjectClose = function(plugin, project)
      if watcher then watcher:RemoveAll() end
      watchers = {}
    end,

    -- watcher can only be properly setup when MainLoop is already running, so use first idle event
    onIdleOnce = function(plugin)
      if not ide.config.filetree.showchanges or not wx.wxFileSystemWatcher then return end
      if not watcher then
        watcher = wx.wxFileSystemWatcher()
        watcher:SetOwner(ide:GetMainFrame())

        local needrefresh = {}
        ide:GetMainFrame():Connect(wx.wxEVT_FSWATCHER, function(event)
            -- using `GetNewPath` to make it work with rename operations
            needrefresh[event:GetNewPath():GetFullPath()] = event:GetChangeType()
            ide:DoWhenIdle(function()
                for file, kind in pairs(needrefresh) do
                  -- if the file is removed, try to find a non-existing file in the same folder
                  -- as this will trigger a refresh of that folder
                  local path = MergeFullPath(file, kind == wx.wxFSW_EVENT_DELETE and "../\1"  or "")
                  local tree = ide:GetProjectTree() -- project tree may be hidden/disabled
                  if ide:IsValidCtrl(tree) then tree:FindItem(path) end
                end
                needrefresh = {}
              end)
          end)
      end
      -- start watching cached paths
      for path, active in pairs(watchers) do
        if not active then watchDir(path) end
      end
    end,

    -- check on PreExpand when expanding (as the folder may not expand if it's empty)
    onFiletreePreExpand = function(plugin, tree, event, item_id)
      watchDir(tree:GetItemFullName(item_id))
    end,

    -- check on Collapse when collapsing to make sure it's unwatched only when collapsed
    onFiletreeCollapse = function(plugin, tree, event, item_id)
      -- only unwatch if the directory is not empty;
      -- otherwise it's collapsed without ability to expand
      if tree:GetChildrenCount(item_id, false) > 0 then unWatchDir(tree:GetItemFullName(item_id)) end
    end,

    onEditorFocusSet = function(plugin, editor)
      -- need to delay the sync, as the document may not yet exist for the editor
      ide:DoWhenIdle(function() if ide:IsValidCtrl(editor) then syncTree(editor) end end,
        "core.filetree.oneditorfocusset")
    end,

    onEditorClose = function(plugin, editor)
      -- check if the last document is being closed
      if #ide:GetDocumentList() <= 1 then syncTree() end
    end,
  })
MergeSettings(filetree.settings, package:GetSettings())
