Skip to content

Instantly share code, notes, and snippets.

@KarloSiric
Created May 11, 2025 20:37
Show Gist options
  • Select an option

  • Save KarloSiric/2ce085d879eefc8bd2e5730599699a42 to your computer and use it in GitHub Desktop.

Select an option

Save KarloSiric/2ce085d879eefc8bd2e5730599699a42 to your computer and use it in GitHub Desktop.
FileBrowser Modified Files
# coding: utf-8
'''Common stuff, used in other modules'''
# import traceback
import re
import os
import fnmatch
import itertools
import sublime
from sublime import Region
from os.path import isdir, join, basename
try: # unavailable dependencies shall not break basic functionality
import package_events
except ImportError:
package_events = None
PLATFORM = sublime.platform()
NT = PLATFORM == 'windows'
LIN = PLATFORM == 'linux'
OSX = PLATFORM == 'osx'
if NT:
import ctypes
MARK_OPTIONS = sublime.DRAW_NO_OUTLINE
RE_FILE = re.compile(r'^(\s*)([^\\//].*)$')
PARENT_SYM = "⠤"
# This is a modification to handle consistent icon display
# Define the ICONS dictionary at the top level of your module
# (outside of the prepare_filelist method) so it can be accessed by other functions
ICONS = {
# Folders - keep the original arrow for functionality
'folder': '▸',
'folder_open': '▾',
# Folder icons
'folder_collapsed': '',
'folder_expanded': '', # Different icon for expanded folders
# Files - keeping ≡ for functionality but adding icons
'default': '',
'py': '󰌠',
'js': '',
'html': '󰌝',
'css': '󰌜',
'json': '',
'md': '󰍔',
'cpp': '',
'c': '',
'h': '',
'java': '',
'txt': '',
'pdf': '󰈦',
'zip': '',
'git': '󰊢',
'png': '󰸭',
'jpg': '󰈥',
'svg': '󰜡',
'webp': '󰏜',
'gif': '󰵸',
'bmp': '󰏜',
'sql': '',
'sqlite': '',
'db': '',
'xls': '',
'csv': '',
'xlsx': '',
'docx': '',
'doc': '',
'ppt': '',
'pptx': '',
'key': '',
'pages': '',
'numbers': '',
'php': '',
'sh': '',
'bash': '',
}
# THiS is a working code so do not modify it
# def get_file_icon(filename):
# ext = filename.split('.')[-1].lower() if '.' in filename else ''
# icon = ICONS.get(ext, ICONS['default'])
# print(f"Getting icon for {filename}: extension={ext}, icon={icon}")
# return icon
def get_file_icon(filename):
ext = filename.split('.')[-1].lower() if '.' in filename else ''
icon = ICONS.get(ext, ICONS['default'])
return icon
def first(seq, pred):
'''similar to built-in any() but return the object instead of boolean'''
return next((item for item in seq if pred(item)), None)
def sort_nicely(names):
""" Sort the given list in the way that humans expect.
Source: http://www.codinghorror.com/blog/2007/12/sorting-for-humans-natural-sort-order.html
"""
convert = lambda text: int(text) if text.isdigit() else text.lower()
alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)]
names.sort(key=alphanum_key)
# WORKING CODE DO NOT MODIFY!!!!! 21.2.2025
# def set_proper_scheme(view):
# '''
# this is callback, it is not meant to be called directly
# view.settings().add_on_change('color_scheme', lambda: set_proper_scheme(view))
# set once, right after view is created
# _note_, color_scheme must not be set directly, but in a setting file
# '''
# # Since we cannot create file with syntax, there is moment when view has no settings,
# # but it is activated, so some plugins (e.g. Color Highlighter) set wrong color scheme
# if view.settings().get('dired_rename_mode', False):
# dired_settings = sublime.load_settings('dired-rename-mode.sublime-settings')
# else:
# dired_settings = sublime.load_settings('dired.sublime-settings')
# color_scheme = dired_settings.get('color_scheme')
# if view.settings().get('color_scheme') == color_scheme:
# return
# if color_scheme:
# view.settings().set('color_scheme', color_scheme)
# else:
# view.settings().erase('color_scheme')
def set_proper_scheme(view):
'''
this is callback, it is not meant to be called directly
view.settings().add_on_change('color_scheme', lambda: set_proper_scheme(view))
set once, right after view is created
_note_, color_scheme must not be set directly, but in a setting file
'''
# Since we cannot create file with syntax, there is moment when view has no settings,
# but it is activated, so some plugins (e.g. Color Highlighter) set wrong color scheme
if view.settings().get('dired_rename_mode', False):
dired_settings = sublime.load_settings('dired-rename-mode.sublime-settings')
else:
dired_settings = sublime.load_settings('dired.sublime-settings')
color_scheme = dired_settings.get('color_scheme')
if view.settings().get('color_scheme') == color_scheme:
return
if color_scheme:
view.settings().set('color_scheme', color_scheme)
else:
view.settings().erase('color_scheme')
def calc_width(view):
'''
return float width, which must be
0.0 < width < 1.0 (other values acceptable, but cause unfriendly layout)
used in show.show() and "dired_select" command with other_group=True
'''
width = view.settings().get('dired_width', 0.3)
if isinstance(width, float):
width -= width // 1 # must be less than 1
elif isinstance(width, int): # assume it is pixels
wport = view.viewport_extent()[0]
width = 1 - round((wport - width) / wport, 2)
if width >= 1:
width = 0.9
else:
sublime.error_message(
'FileBrowser:\n\ndired_width set to '
'unacceptable type "{0}", please change it.\n\n'
'Fallback to default 0.3 for now.'
.format(type(width))
)
width = 0.3
return width or 0.1 # avoid 0.0
def get_group(groups, nag):
'''
groups amount of groups in window
nag number of active group
return number of neighbour group
'''
if groups <= 4 and nag < 2:
group = 1 if nag == 0 else 0
elif groups == 4 and nag >= 2:
group = 3 if nag == 2 else 2
else:
group = nag - 1
return group
def relative_path(rpath):
'''rpath is either list or empty string (if list, we need only first item);
return either empty string or rpath[0] (or its parent), e.g.
foo/bar/ → foo/bar/
foo/bar → foo/
'''
if rpath:
rpath = rpath[0]
if rpath[~0] != os.sep:
rpath = os.path.split(rpath)[0] + os.sep
if rpath == os.sep:
rpath = ''
return rpath
def hijack_window():
'''Execute on loading plugin or on new window open;
allow to open FileBrowser automatically
'''
settings = sublime.load_settings('dired.sublime-settings')
command = settings.get("dired_hijack_new_window")
if command:
if command == "jump_list":
sublime.set_timeout(lambda: sublime.windows()[-1].run_command("dired_jump_list"), 1)
else:
sublime.set_timeout(
lambda: sublime.windows()[-1].run_command("dired", {"immediate": True}), 1)
# def emit_event(event_type, payload, view=None, plugin='FileBrowser'):
# '''Notify our filesystem observer about changes in our views
# event_type
# Unicode object tells what happen, i.e set_paths, remove_path, view_closed, etc.
# payload
# some info for observer, e.g. id of view, list of paths, tuple of those things
# must be immutable object
# view
# sublime.View object or None
# plugin
# Unicode object tells who is sender, in order to address event to certain listener
# we have two different listeners for FileBrowser and FileBrowserWFS
# FileBrowser
# notifies observer about user actions to adjust scheduling paths
# FileBrowserWFS
# notifies FileSystemEventHandler about scheduled paths in order to schedule refresh when
# sth is changed on file system
# '''
# if package_events is None:
# return
# if view and not view.settings().get('dired_autorefresh', True):
# package_events.notify(plugin, 'stop_watch', view.id())
# return
# package_events.notify(plugin, event_type, payload)
# -------------------- TESTING ONLY --------------------
def emit_event(event_type, payload, view=None, plugin='FileBrowser'):
'''Notify our filesystem observer about changes in our views'''
# Block refreshes caused by system files or temporary directories
IGNORED_PATHS = ["/Library/Preferences", "/private/var/folders", "/tmp", "/var/log"]
if any(ignored in str(payload) for ignored in IGNORED_PATHS):
print(f"🚫 Ignoring event: {event_type}, payload: {payload}") # Debugging
return # Don't trigger refresh for these paths
if package_events is None:
return
if view and not view.settings().get('dired_autorefresh', True):
package_events.notify(plugin, 'stop_watch', view.id())
return
package_events.notify(plugin, event_type, payload)
class DiredBaseCommand:
"""
Convenience functions for dired TextCommands
"""
@property
def path(self):
return self.view.settings().get('dired_path')
# -------- ORIGINAL CODE --------
# def get_path(self):
# path = self.path
# if path == 'ThisPC\\':
# path = ''
# return path
def get_path(self):
path = self.path
if path == 'ThisPC\\':
path = ''
return path
def filecount(self):
"""
Returns the number of files and directories in the view.
"""
return self.view.settings().get('dired_count', 0)
def move_to_extreme(self, extreme="bof"):
"""
Moves the cursor to the beginning or end of file list. Clears all sections.
"""
files = self.fileregion(with_parent_link=True)
self.view.sel().clear()
if extreme == "bof":
ext_region = Region(files.a, files.a)
else:
name_point = self.view.extract_scope(self.view.line(files.b).a + 2).a
ext_region = Region(name_point, name_point)
self.view.sel().add(ext_region)
self.view.show_at_center(ext_region)
def move(self, forward=None):
"""
Moves the cursor one line forward or backwards. Clears all sections.
"""
assert forward in (True, False), 'forward must be set to True or False'
files = self.fileregion(with_parent_link=True)
if not files:
return
new_sels = []
for s in list(self.view.sel()):
new_sels.append(self._get_name_point(self.next_line(forward, s.a, files)))
self.view.sel().clear()
for n in new_sels:
self.view.sel().add(Region(n, n))
name_point = new_sels[~0] if forward else new_sels[0]
surroundings = True if self.view.rowcol(name_point)[0] < 3 else False
self.view.show(name_point, surroundings)
def next_line(self, forward, pt, filergn):
'''Return Region of line for pt within filergn'''
if filergn.contains(pt):
# Try moving by one line.
line = self.view.line(pt)
pt = forward and (line.b + 1) or (line.a - 1)
if not filergn.contains(pt):
# Not (or no longer) in the list of files, so move to the closest edge.
pt = (pt > filergn.b) and filergn.b or filergn.a
return self.view.line(pt)
def _get_name_point(self, line):
'''Return point at which filename starts (i.e. after icon & whitespace)'''
scope = self.view.scope_name(line.a)
if 'indent' in scope:
name_point = self.view.extract_scope(line.a).b
else:
name_point = line.a
return name_point + (2 if 'parent_dir' not in scope else 0)
def show_parent(self):
return self.view.settings().get('dired_show_parent', False)
def fileregion(self, with_parent_link=False):
"""
Returns a region containing the lines containing filenames.
If there are no filenames None is returned.
"""
if with_parent_link:
all_items = sorted(self.view.find_by_selector('dired.item'))
else:
all_items = sorted(self.view.find_by_selector('dired.item.directory') +
self.view.find_by_selector('dired.item.file'))
if not all_items:
return None
return Region(all_items[0].a, all_items[~0].b)
def get_parent(self, line, path):
'''
Returns relative path for line
• line is a region
• path is self.path
• self.index is list stored in view settings as 'dired_index'
'''
return self.get_fullpath_for(line).replace(path, '', 1)
def get_fullpath_for(self, line):
return self.index[self.view.rowcol(line.a)[0]]
def get_all(self):
"""
Returns a list of all filenames in the view.
dired_index is always supposed to represent current state of view,
each item matches corresponding line, thus list will never be empty unless sth went wrong;
if header is enabled then first two elements are empty strings
"""
index = self.view.settings().get('dired_index', [])
if not index:
return sublime.error_message('FileBrowser:\n\n"dired_index" is empty,\n'
'that shouldn’t happen ever, there is some bug.')
return index
def get_all_relative(self, path):
return [f.replace(path, '', 1) for f in self.get_all()]
def get_selected(self, parent=True, full=False):
"""
parent
if False, returned list does not contain PARENT_SYM even if it is in view
full
if True, items in returned list are full paths, else relative
Returns a list of selected filenames.
self.index should be assigned before call it
"""
fileregion = self.fileregion(with_parent_link=parent)
if not fileregion:
return None
path = self.get_path()
names = []
for line in self._get_lines([s for s in self.view.sel()], fileregion):
text = self.get_fullpath_for(line) if full else self.get_parent(line, path)
if text and text not in names:
names.append(text)
return names
def get_marked(self, full=False):
'''self.index should be assigned before call it'''
if not self.filecount():
return []
path = self.get_path()
names = []
for line in self.view.get_regions('marked'):
text = self.get_fullpath_for(line) if full else self.get_parent(line, path)
if text and text not in names:
names.append(text)
return names
def _mark(self, mark, regions):
"""
Marks the requested files.
mark
True, False, or a function with signature `func(oldmark, filename)`.
The function should return True or False.
regions
List of region(s). Only files within the region will be modified.
"""
filergn = self.fileregion()
if not filergn:
return
self.index = self.get_all()
# We can't update regions for a key, only replace, so we need to record the existing marks.
marked = {
self.get_fullpath_for(r): r
for r in self.view.get_regions('marked') if not r.empty()
}
for line in self._get_lines(regions, filergn):
filename = self.get_fullpath_for(line)
if mark not in (True, False):
newmark = mark(filename in marked, filename)
assert newmark in (True, False), 'Invalid mark: {0}'.format(newmark)
else:
newmark = mark
if newmark:
name_point = self._get_name_point(line)
marked[filename] = Region(name_point, line.b)
else:
marked.pop(filename, None)
if marked:
r = sorted(list(marked.values()), key=lambda region: region.a)
self.view.add_regions('marked', r, 'dired.marked', '', MARK_OPTIONS)
else:
self.view.erase_regions('marked')
def _get_lines(self, regions, within):
'''
regions is a list of non-overlapping region(s), each may have many lines
within is a region which is supposed to contain each line
'''
return (
line
for line in itertools.chain(*(self.view.lines(r) for r in regions))
if within.contains(line)
)
# -------- ORGINAL CODE --------
# def set_ui_in_rename_mode(self, edit):
# header = self.view.settings().get('dired_header', False)
# if header:
# regions = self.view.find_by_selector(
# 'text.dired header.dired punctuation.definition.separator.dired')
# else:
# regions = self.view.find_by_selector('text.dired dired.item.parent_dir')
# if not regions:
# return
# region = regions[0]
# start = region.begin()
# self.view.erase(edit, region)
# if header:
# new_text = "——[RENAME MODE]——" + ("—" * (region.size() - 17))
# else:
# new_text = "⠤ [RENAME MODE]"
# self.view.insert(edit, start, new_text)
def set_ui_in_rename_mode(self, edit):
header = self.view.settings().get('dired_header', False)
if header:
regions = self.view.find_by_selector(
'text.dired header.dired punctuation.definition.separator.dired')
else:
regions = self.view.find_by_selector('text.dired dired.item.parent_dir')
if not regions:
return
region = regions[0]
start = region.begin()
self.view.erase(edit, region)
if header:
new_text = "——[RENAME MODE]——" + ("—" * (region.size() - 17))
else:
new_text = "⠤ [RENAME MODE]"
self.view.insert(edit, start, new_text)
# Store the fact that we're in rename mode
self.view.settings().set('dired_rename_mode', True)
def set_status(self):
'''Update status-bar;
self.show_hidden must be assigned before call it'''
# if view isnot focused, view.window() may be None
window = self.view.window() or sublime.active_window()
path_in_project = any(folder == self.path[:-1] for folder in window.folders())
settings = self.view.settings()
copied_items = settings.get('dired_to_copy', [])
cut_items = settings.get('dired_to_move', [])
status = " 𝌆 [?: Help] {0}Hidden: {1}{2}{3}".format(
'Project root, ' if path_in_project else '',
'On' if self.show_hidden else 'Off',
', copied(%d)' % len(copied_items) if copied_items else '',
', cut(%d)' % len(cut_items) if cut_items else ''
)
self.view.set_status("__FileBrowser__", status)
# ---------- WORKING ICONS ------------------
# ---------- NEEDS TO BE FIXED STILL ----------
# def prepare_filelist(self, names, path, goto, indent):
# Add the icons dictionary at the start of the method
# adding this new line so that we can trace what called this method
# and debug properly.
# print("prepare_filelist called by:", traceback.format_stack()[-2])
# ICONS = {
# # Folders - keep the original arrow for functionality
# 'folder': '▸', # Keep this arrow for functionality
# 'folder_open': '▾', # Keep for functionality
# # Files - keeping ≡ for functionality but adding icons
# 'default': '󰈔',
# 'py': '󰌠',
# 'js': '󰌞',
# 'html': '󰌝',
# 'css': '󰌜',
# 'json': '󰘦',
# 'md': '󰍔',
# 'cpp': '󰙲',
# 'c': '󰙱',
# 'h': '󰙲',
# 'java': '󰬷',
# 'txt': '󰈙',
# 'pdf': '󰈦',
# 'zip': '󰿺',
# 'git': '󰊢',
# }
# def get_file_icon(filename):
# ext = filename.split('.')[-1].lower() if '.' in filename else ''
# return ICONS.get(ext, ICONS['default'])
# items = []
# tab = self.view.settings().get('tab_size')
# line = self.view.line(self.sel.a if self.sel is not None else self.view.sel()[0].a)
# content = self.view.substr(line).replace('\t', ' ' * tab)
# ind = re.compile(r'^(\s*)').match(content).group(1)
# level = indent * int((len(ind) / tab) + 1) if ind else indent
# files = []
# index_dirs = []
# index_files = []
# for name in names:
# full_name = join(path, goto, name)
# if isdir(full_name):
# index_dirs.append('%s%s' % (full_name, os.sep))
# # Keep the original ▸ for folders but add icon
# items.append(''.join([level, "▸ 󰉋 ", name, os.sep]))
# else:
# index_files.append(full_name)
# icon = get_file_icon(name)
# # Keep the original ≡ for files but add icon
# files.append(''.join([level, "≡ " + icon + " ", name]))
# index = index_dirs + index_files
# self.index = self.index[:self.number_line] + index + self.index[self.number_line:]
# items += files
# return items
# ---------- WORKING ICONS ------------------
# ----------- LATEST WORKING 21.2.2025 ----------------
# def prepare_filelist(self, names, path, goto, indent):
# # Add debug output
# print(f"prepare_filelist called with {len(names)} items")
# sample_names = names[:5] if names else []
# print(f"Sample names: {sample_names}")
# items = []
# tab = self.view.settings().get('tab_size')
# line = self.view.line(self.sel.a if self.sel is not None else self.view.sel()[0].a)
# content = self.view.substr(line).replace('\t', ' ' * tab)
# ind = re.compile(r'^(\s*)').match(content).group(1)
# level = indent * int((len(ind) / tab) + 1) if ind else indent
# files = []
# index_dirs = []
# index_files = []
# for name in names:
# full_name = join(path, goto, name)
# if isdir(full_name):
# index_dirs.append('%s%s' % (full_name, os.sep))
# items.append(''.join([level, "▸ 󰉋 ", name, os.sep]))
# else:
# index_files.append(full_name)
# icon = get_file_icon(name)
# # Make sure file icons are displayed correctly
# files.append(''.join([level, "≡ ", icon, " ", name]))
# index = index_dirs + index_files
# self.index = self.index[:self.number_line] + index + self.index[self.number_line:]
# items += files
# return items
def prepare_filelist(self, names, path, goto, indent):
items = []
tab = self.view.settings().get('tab_size')
line = self.view.line(self.sel.a if self.sel is not None else self.view.sel()[0].a)
content = self.view.substr(line).replace('\t', ' ' * tab)
ind = re.compile(r'^(\s*)').match(content).group(1)
level = indent * int((len(ind) / tab) + 1) if ind else indent
files = []
index_dirs = []
index_files = []
for name in names:
full_name = join(path, goto, name)
if isdir(full_name):
index_dirs.append('%s%s' % (full_name, os.sep))
# Use the collapsed folder icon
items.append(''.join([level, "▸ ", ICONS['folder_collapsed'], " ", name, os.sep]))
else:
index_files.append(full_name)
icon = get_file_icon(name)
files.append(''.join([level, "≡ ", icon, " ", name]))
index = index_dirs + index_files
self.index = self.index[:self.number_line] + index + self.index[self.number_line:]
items += files
return items
def is_hidden(self, filename, path, goto=''):
if not (path or goto): # special case for ThisPC
return False
tests = self.view.settings().get('dired_hidden_files_patterns', ['.*'])
if isinstance(tests, str):
tests = [tests]
if any(fnmatch.fnmatch(filename, pattern) for pattern in tests):
return True
if sublime.platform() != 'windows':
return False
# check for attribute on windows:
try:
attrs = ctypes.windll.kernel32.GetFileAttributesW(join(path, goto, filename))
assert attrs != -1
result = bool(attrs & 2)
except (AttributeError, AssertionError):
result = False
return result
def try_listing_directory(self, path):
'''Return tuple of two element
items sorted list of filenames in path, or empty list
error exception message, or empty string
'''
items, error = [], ''
try:
if not self.show_hidden:
items = [name for name in os.listdir(path) if not self.is_hidden(name, path)]
else:
items = os.listdir(path)
except OSError as e:
error = str(e)
if NT:
error = (
error
.split(':')[0]
.replace('[Error 5] ', 'Access denied')
.replace('[Error 3] ', 'Not exists, press r to refresh')
)
else:
sort_nicely(items)
finally:
return items, error
def try_listing_only_dirs(self, path):
'''Same as self.try_listing_directory, but items contains only directories.
Used for prompt completion'''
items, error = self.try_listing_directory(path)
if items:
items = [n for n in items if isdir(join(path, n))]
return (items, error)
def restore_marks(self, marked=None):
if marked:
# Even if we have the same filenames, they may have moved so we have to manually
# find them again.
path = self.get_path()
regions = []
for mark in marked:
matches = self._find_in_view(mark)
for region in matches:
filename = self.get_parent(region, path)
if filename == mark:
regions.append(region)
# if it is found, no need to check other matches, so break
break
self._mark(mark=True, regions=regions)
else:
self.view.erase_regions('marked')
def restore_sels(self, sels=None):
'''
sels is tuple of two elements:
0 list of filenames
relative paths to search in the view
1 list of Regions
copy of view.sel(), used for fallback if filenames are not found
in view (e.g. user deleted selected file)
'''
if sels:
seled_fnames, seled_regions = sels
path = self.get_path()
regions = []
for selection in seled_fnames:
matches = self._find_in_view(selection)
for region in matches:
filename = self.get_parent(region, path)
if filename == selection:
name_point = self._get_name_point(region)
regions.append(Region(name_point, name_point))
break
if regions:
return self._add_sels(regions)
else:
# e.g. when user remove file(s), we just restore sel RegionSet
# despite positions may be wrong sometimes
return self._add_sels(seled_regions)
# fallback:
return self._add_sels()
# ------------- ORIGINAL CODE -----------------
# def _find_in_view(self, item):
# '''item is Unicode'''
# fname = re.escape(basename(os.path.abspath(item)) or item.rstrip(os.sep))
# if item[~0] == os.sep:
# pattern = r'^\s*[▸▾] '
# sep = re.escape(os.sep)
# else:
# pattern = r'^\s*≡ '
# sep = ''
# return self.view.find_all('%s%s%s' % (pattern, fname, sep))
# Fix the _find_in_view method to work with icons
def _find_in_view(self, item):
'''item is Unicode'''
fname = re.escape(basename(os.path.abspath(item)) or item.rstrip(os.sep))
if item[~0] == os.sep:
# For directories - use a more flexible pattern
pattern = r'^\s*[▸▾].*?'
sep = re.escape(os.sep)
else:
# For files - use a more flexible pattern
pattern = r'^\s*≡.*?'
sep = ''
return self.view.find_all('%s%s%s' % (pattern, fname, sep))
def _add_sels(self, sels=None):
self.view.sel().clear()
if sels:
eof = self.view.size()
for s in sels:
if s.begin() <= eof:
self.view.sel().add(s)
if not sels or not list(self.view.sel()): # all sels more than eof
fbs = self.view.find_by_selector
item = (
fbs('text.dired dired.item.parent_dir ')
or fbs('text.dired dired.item.directory string.name.directory.dired ')
or fbs('text.dired dired.item.file string.name.file.dired ')
)
s = Region(item[0].a, item[0].a) if item else Region(0, 0)
self.view.sel().add(s)
self.view.show_at_center(s)
def display_path(self, folder):
display = folder
home = os.path.expanduser("~")
if folder.startswith(home):
display = folder.replace(home, "~", 1)
return display
# coding: utf-8
'''Main module; launch and navigation related stuff'''
from collections import defaultdict
import os
from os.path import basename, dirname, isdir, exists, join
import sys
from textwrap import dedent
import sublime
from sublime import Region
from sublime_plugin import EventListener, WindowCommand, TextCommand
from .common import (
DiredBaseCommand, set_proper_scheme, calc_width, get_group, hijack_window, emit_event,
NT, PARENT_SYM, get_file_icon, ICONS)
from . import prompt
from .show import show
from .jumping import jump_names
def reuse_view():
return sublime.load_settings('dired.sublime-settings').get('dired_reuse_view', False)
def plugin_loaded():
if len(sublime.windows()) == 1 and len(sublime.windows()[0].views()) == 0:
hijack_window()
for w in sublime.windows():
for v in w.views():
if v.settings() and v.settings().get("dired_path"):
# reset sels because dired_index not exists yet, so we can't restore sels
v.run_command("dired_refresh", {"reset_sels": True})
dfsobserver = 'FileBrowser.0_dired_fs_observer'
if dfsobserver not in sys.modules or sys.modules[dfsobserver].Observer is None:
print(dedent('''
FileBrowser: watchdog module is not importable, hence auto-refresh will not work.
You can still manually refresh a dired view with `[r]`.
• If you installed via Package Control, make sure to restart Sublime Text often enough
to give it a chance to install the required dependencies.
• If you installed manually, then refer the Readme on how to install dependencies as well.
''')) # noqa: E501
settings = sublime.load_settings('dired.sublime-settings')
settings.add_on_change(
'dired_autorefresh',
lambda: emit_event('toggle_watch_all', settings.get('dired_autorefresh', None))
)
def plugin_unloaded():
sublime.load_settings('dired.sublime-settings').clear_on_change('dired_autorefresh')
class DiredCommand(WindowCommand, DiredBaseCommand):
"""
Open a dired view. This is the main entrypoint/constructor.
If `immediate` is set: open a dired view immediately. Usually highlight the
file of the current view, fallback to an open folder or the users home
directory as a last resort. If `project` is also set: set the root
directory to the best matching open folder. Otherwise the root will be
the directory of the view's file. (In other words, you get either a flat,
if `project: false`, or nested listing, if it is `true`.)
If `immediate` is *not* set: open an input box to let the user type in the
directory they want. The input box implements a completion helper using
`<tab>` to make this easier. The input box is typically filled with the
name of the directory of the active view's file, or a fallback. If you
set `project`, it is instead pre-filled with the folder attached to the
window. In case there are multiple folders open, the user gets prompted
by a quick panel to choose a folder from. In case there are *no* folders,
show some fallbacks.
"""
def run(self, immediate=False, project=False, single_pane=False, other_group=False):
if immediate:
fpath = self._get_current_fpath()
if not fpath:
path, goto = self._fallback_path(), ''
elif project:
path = self._best_root_for_fpath(fpath)
if path:
goto = os.path.relpath(fpath, path)
else:
path, goto = os.path.split(fpath)
else:
path, goto = os.path.split(fpath)
show(self.window, path, goto=goto, single_pane=single_pane, other_group=other_group)
return
if project:
folders = self.window.folders()
if len(folders) == 1:
path = folders[0]
else:
self._show_folders_panel(folders, single_pane, other_group)
return
else:
fpath = self._get_current_fpath()
path = os.path.dirname(fpath) if fpath else self._fallback_path()
prompt.start('Directory:', self.window, path, self._show, single_pane, other_group)
def _get_current_fpath(self):
view = self.window.active_view()
return view.file_name() if view else None
def _show_folders_panel(self, folders, single_pane, other_group):
if not folders:
fpath = self._get_current_fpath()
if fpath:
folders += [os.path.dirname(fpath)]
folders += [os.path.expanduser('~')]
names = [basename(f) for f in folders]
longest_name = max([len(n) for n in names])
for i, f in enumerate(folders):
name = names[i]
offset = ' ' * (longest_name - len(name) + 1)
names[i] = '%s%s%s' % (name, offset, self.display_path(f))
self.window.show_quick_panel(
names,
lambda i: self._show_folder(i, folders, single_pane, other_group),
sublime.MONOSPACE_FONT
)
def _fallback_path(self):
folders = self.window.folders()
return folders[0] if folders else os.path.expanduser('~')
def _best_root_for_fpath(self, fpath):
folders = self.window.folders()
for f in folders:
# e.g. ['/a', '/aa'], to open '/aa/f' we need '/aa/'
if fpath.startswith(''.join([f, os.sep])):
return f
def _show_folder(self, index, folders, single_pane, other_group):
if index != -1:
path = folders[index]
show(self.window, path, single_pane=single_pane, other_group=other_group)
def _show(self, path, single_pane, other_group):
show(self.window, path, single_pane=single_pane, other_group=other_group)
class DiredRefreshCommand(TextCommand, DiredBaseCommand):
"""
Populates or repopulates a dired view.
self.index is a representation of view lines
list contains full path of each item in a view, except
header ['', ''] and parent_dir [PARENT_SYM]
self.index shall be updated according to view modifications (refresh,
expand single directory, fold) and stored in view settings as 'dired_index'
The main reason for index is access speed to item path because we can
self.index[self.view.rowcol(region.a)[0]]
to get full path, instead of grinding with substr thru entire view
substr is slow: https://github.com/SublimeTextIssues/Core/issues/882
"""
def run(self, edit, goto='', to_expand=None, toggle=None, reset_sels=None):
"""
goto
Optional filename to put the cursor on; used only from "dired_up"
to_expand
List of relative paths for directories which shall be expanded
toggle
If true, marked/selected directories shall switch state,
i.e. expand/collapse
reset_sels
If True, previous selections & marks shan’t be restored
"""
# after restart ST, callback seems to disappear, so reset callback on each refresh
# for more reliability
self.view.settings().clear_on_change('color_scheme')
self.view.settings().add_on_change('color_scheme', lambda: set_proper_scheme(self.view))
path = self.path
names = []
if path == 'ThisPC\\':
path, names = '', self.get_disks()
if path and not exists(path):
if sublime.ok_cancel_dialog(
(
'FileBrowser:\n\n'
'Directory does not exist:\n\n'
'\t{0}\n\nTry to go up?'.format(path)
),
'Go'
):
self.view.run_command('dired_up')
return
self.expanded = expanded = self.view.find_all(r'^\s*▾') if not reset_sels else []
self.show_hidden = self.view.settings().get('dired_show_hidden_files', True)
self.goto = goto
if os.sep in goto:
to_expand = self.expand_goto(to_expand)
self.number_line = 0
if reset_sels and not to_expand:
self.index, self.marked, self.sels = [], None, None
self.populate_view(edit, path, names)
else:
if not reset_sels:
self.index = self.get_all()
self.marked = self.get_marked()
self.sels = (self.get_selected(), list(self.view.sel()))
else:
self.marked, self.sels = None, None
self.re_populate_view(edit, path, names, expanded, to_expand, toggle)
emit_event(
'set_paths',
(self.view.id(), self.expanded + ([path] if path else [])),
view=self.view
)
def expand_goto(self, to_expand):
'''e.g. self.goto = "a/b/c/d/", then to put cursor onto d, it should be
to_expand = ["a/", "a/b/", "a/b/c/"] (items order in list dont matter)
'''
to_expand = to_expand or []
goto = self.goto
while len(goto.split(os.sep)) > 2:
parent = dirname(goto) + os.sep
to_expand.append(parent)
goto = parent.rstrip(os.sep)
return to_expand
def re_populate_view(self, edit, path, names, expanded, to_expand, toggle):
'''Called when we know that some directories were (or/and need to be) expanded'''
root = path
for i, r in enumerate(expanded):
name = self.get_fullpath_for(r)
expanded[i] = name
if toggle and to_expand:
merged = list(set(expanded + to_expand))
expanded = [e for e in merged if not (e in expanded and e in to_expand)]
else:
expanded.extend(to_expand or [])
self.expanded = expanded
# we need prev index to setup expanded list — done, so reset index
self.index = []
tree = self.traverse_tree(root, root, '', names, expanded)
if not tree:
return self.populate_view(edit, path, names)
self.set_status()
items = self.correcting_index(path, tree)
self.write(edit, items)
self.restore_selections(path)
self.view.run_command('dired_call_vcs', {'path': path})
def populate_view(self, edit, path, names):
'''Called when no directories were (or/and need to be) expanded'''
if not path and names: # open ThisPC
self.continue_populate(edit, path, names)
return
items, error = self.try_listing_directory(path)
if error:
self.view.run_command("dired_up")
self.view.set_read_only(False)
self.view.insert(
edit,
self.view.line(self.view.sel()[0]).b,
'\t<%s>' % error
)
self.view.set_read_only(True)
else:
self.continue_populate(edit, path, items)
def continue_populate(self, edit, path, names):
'''Called if there is no exception in self.populate_view'''
self.sel = None
self.set_status()
items = self.correcting_index(path, self.prepare_filelist(names, path, '', ''))
self.write(edit, items)
self.restore_selections(path)
self.view.run_command('dired_call_vcs', {'path': path})
# --------------------------- ORIGINAL CODE DO NOT MODIFY, BACKUP 22.2.2025!!! ------------------------------
# def traverse_tree(self, root, path, indent, tree, expanded):
# '''Recursively build list of filenames for self.re_populate_view'''
# if not path: # special case for ThisPC, path is empty string
# items = ['%s\\' % d for d in tree]
# tree = []
# else:
# if indent: # this happens during recursive call, i.e. path in expanded
# # basename return funny results for c:\\ so it is tricky
# bname = os.path.basename(os.path.abspath(path)) or path.rstrip(os.sep)
# # Use the expanded folder icon
# tree.append('%s▾ %s %s%s' % (indent[:-1], ICONS['folder_expanded'], bname.rstrip(os.sep), os.sep))
# self.index.append('%s' % path)
# items, error = self.try_listing_directory(path)
# if error:
# tree[~0] += '\t<%s>' % error
# return
# if not items:
# if path == root:
# return []
# # expanding empty folder, so notify that it is empty
# tree[~0] += '\t<empty>'
# return
# files = []
# index_files = []
# for f in items:
# new_path = join(path, f)
# dir_path = '%s%s' % (new_path.rstrip(os.sep), os.sep)
# check = isdir(new_path)
# if check and dir_path in expanded:
# self.traverse_tree(root, dir_path, indent + '\t', tree, expanded)
# elif check:
# self.index.append(dir_path)
# # Use the collapsed folder icon
# tree.append('%s▸ %s %s%s' % (indent, ICONS['folder_collapsed'], f.rstrip(os.sep), os.sep))
# else:
# index_files.append(new_path)
# icon = get_file_icon(f)
# files.append('%s≡ %s %s' % (indent, icon, f))
# self.index += index_files
# tree += files
# return tree
def traverse_tree(self, root, path, indent, tree, expanded):
'''Recursively build list of filenames for self.re_populate_view'''
if not path: # special case for ThisPC, path is empty string
items = ['%s\\' % d for d in tree]
tree = []
else:
if indent: # this happens during recursive call, i.e. path in expanded
# basename return funny results for c:\\ so it is tricky
bname = os.path.basename(os.path.abspath(path)) or path.rstrip(os.sep)
# Use the expanded folder icon
tree.append('%s▾ %s %s%s' % (indent[:-1], ICONS['folder_expanded'], bname.rstrip(os.sep), os.sep))
self.index.append('%s' % path)
items, error = self.try_listing_directory(path)
if error:
tree[~0] += '\t<%s>' % error
return
if not items:
if path == root:
return []
# expanding empty folder, so notify that it is empty
tree[~0] += '\t<empty>'
return
files = []
index_files = []
for f in items:
new_path = join(path, f)
dir_path = '%s%s' % (new_path.rstrip(os.sep), os.sep)
check = isdir(new_path)
if check and dir_path in expanded:
self.traverse_tree(root, dir_path, indent + '\t', tree, expanded)
elif check:
self.index.append(dir_path)
# Use the collapsed folder icon
tree.append('%s▸ %s %s%s' % (indent, ICONS['folder_collapsed'], f.rstrip(os.sep), os.sep))
else:
index_files.append(new_path)
icon = get_file_icon(f)
files.append('%s≡ %s %s' % (indent, icon, f))
self.index += index_files
tree += files
return tree
def set_title(self, path):
'''Update name of tab and return tuple of two elements
text list of two unicode obj (will be inserted before filenames
in view) or empty list
header boolean, value of dired_header setting
'''
header = self.view.settings().get('dired_header', False)
name = jump_names().get(path or self.path)
caption = "{0} → {1}".format(name, path) if name else path or self.path
text = [caption, len(caption) * '—'] if header else []
icon = self.view.name()[:2]
if not path:
title = '%s%s' % (icon, name or 'This PC')
else:
norm_path = path.rstrip(os.sep)
if self.view.settings().get('dired_show_full_path', False):
title = '%s%s (%s)' % (icon, name or basename(norm_path), norm_path)
else:
title = '%s%s' % (icon, name or basename(norm_path))
self.view.set_name(title)
return (text, header)
def write(self, edit, fileslist):
'''apply changes to view'''
self.view.set_read_only(False)
self.view.replace(edit, Region(0, self.view.size()), '\n'.join(fileslist))
self.view.set_read_only(True)
fileregion = self.fileregion()
count = len(self.view.lines(fileregion)) if fileregion else 0
self.view.settings().set('dired_count', count)
self.view.settings().set('dired_index', self.index)
def correcting_index(self, path, fileslist):
'''Add leading elements to self.index (if any), we need conformity of
elements in self.index and line numbers in view
Return list of unicode objects that ready to be inserted in view
'''
text, header = self.set_title(path)
if path and (not fileslist or self.show_parent()):
text.append(PARENT_SYM)
self.index = [PARENT_SYM] + self.index
self.number_line += 1
if header:
self.index = ['', ''] + self.index
self.number_line += 2
return text + fileslist
def restore_selections(self, path):
'''Set cursor(s) and mark(s)'''
self.restore_marks(self.marked)
if self.goto:
if self.goto[~0] != os.sep:
self.goto += (os.sep if isdir(join(path, self.goto)) else '')
self.sels = ([self.goto.replace(path, '', 1)], None)
self.restore_sels(self.sels)
def get_disks(self):
'''create list of disks on Windows for ThisPC folder'''
names = []
for s in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
disk = '%s:' % s
if isdir(disk):
names.append(disk)
return names
# NAVIGATION #####################################################
class DiredNextLineCommand(TextCommand, DiredBaseCommand):
def run(self, edit, forward=None):
self.move(forward)
class DiredMoveCommand(TextCommand, DiredBaseCommand):
def run(self, edit, to="bof"):
self.move_to_extreme(to)
class DiredSelect(TextCommand, DiredBaseCommand):
'''Common command for opening file/directory in existing view'''
def run(self, edit, new_view=0, other_group=0, and_close=0):
'''
new_view if True, open directory in new view, rather than existing one
other_group if True, create a new group (if need) and open file in this group
and_close if True, close FileBrowser view after file was open
'''
self.index = self.get_all()
filenames = (self.get_selected(full=True) if not new_view else
self.get_marked(full=True) or self.get_selected(full=True))
window = self.view.window()
if self.goto_directory(filenames, window, new_view):
return
dired_view = self.view
if other_group:
self.focus_other_group(window)
else:
groups = window.num_groups()
if groups > 1:
dired_group = window.active_group()
group = get_group(groups, dired_group)
for other_view in window.views_in_group(group):
if other_view.settings().get("dired_preview_view"):
other_view.close()
window.focus_view(dired_view)
self.last_created_view = None
for fqn in filenames:
self.open_item(fqn, window, new_view)
if and_close:
window.focus_view(dired_view)
window.run_command("close")
if self.last_created_view:
window.focus_view(self.last_created_view)
def goto_directory(self, filenames, window, new_view):
'''If reuse view is turned on and the only item is a directory, refresh the existing view'''
if new_view and reuse_view():
return False
fqn = filenames[0]
if len(filenames) == 1 and isdir(fqn):
show(self.view.window(), fqn, view_id=self.view.id())
return True
elif fqn == PARENT_SYM:
self.view.window().run_command("dired_up")
return True
return False
def open_item(self, fqn, window, new_view):
if isdir(fqn):
show(window, fqn, ignore_existing=new_view)
elif exists(fqn): # ignore 'item <error>'
self.last_created_view = window.open_file(fqn, sublime.FORCE_GROUP, group=-1)
else:
sublime.status_message(
'File does not exist ({0})'.format((basename(fqn.rstrip(os.sep)) or fqn))
)
def focus_other_group(self, window):
'''call it when preview open in other group'''
target_group = self._other_group(window, window.active_group())
# set_view_index and focus are not very reliable
# just focus target_group should do what we want
window.focus_group(target_group)
def _other_group(self, w, nag):
'''
creates new group if need and return index of the group where files
shall be opened
'''
groups = w.num_groups()
if groups == 1:
width = calc_width(self.view)
w.set_layout({
"cols": [0.0, width, 1.0],
"rows": [0.0, 1.0],
"cells": [[0, 0, 1, 1], [1, 0, 2, 1]]
})
group = get_group(groups, nag)
return group
class DiredPreviewCommand(DiredSelect):
'''Open file as a preview, so focus remains in FileBrowser view'''
def run(self, edit):
self.index = self.get_all()
filenames = self.get_selected(full=True)
if not filenames:
return sublime.status_message('Nothing to preview')
fqn = filenames[0]
if isdir(fqn) or fqn == PARENT_SYM:
self.view.run_command('dired_preview_directory', {'fqn': fqn})
return
if exists(fqn):
window = self.view.window()
dired_view = self.view
will_create_preview_group = window.num_groups() == 1
group = self._other_group(window, window.active_group())
other_view = window.active_view_in_group(group)
if (
other_view
and other_view.file_name() == fqn
and other_view.settings().get("dired_preview_view")
):
# We need to focus even when we close, otherwise Sublime
# magically opens an empty/new view. `set_timeout` for the
# same reason. TODO: Investigate in safe-mode.
window.focus_view(other_view)
other_view.close()
sublime.set_timeout(lambda: window.focus_view(dired_view))
else:
open_views = window.views_in_group(group)
close_empty_preview_group = (
will_create_preview_group
or (
open_views and all(
other_view.settings().get("dired_preview_view")
for other_view in open_views
)
)
)
other_view = window.open_file(fqn, sublime.FORCE_GROUP, group=group)
other_view.settings().set("dired_preview_view", True)
other_view.settings().set(
"dired_close_empty_preview_pane", close_empty_preview_group)
for v in open_views:
if v != other_view and v.settings().get("dired_preview_view"):
v.close()
when_loaded(other_view, lambda: window.focus_view(dired_view))
else:
sublime.status_message(
'File does not exist ({0})'.format(basename(fqn.rstrip(os.sep)) or fqn))
views_yet_to_get_loaded = defaultdict(list)
preview_to_get_closed = {}
def when_loaded(view, handler):
if view.is_loading():
views_yet_to_get_loaded[view.id()].append(handler)
else:
handler()
class PreviewViewHandler(EventListener):
def on_load(self, view):
callbacks = views_yet_to_get_loaded.pop(view.id(), [])
for fn in callbacks:
sublime.set_timeout(fn)
def on_modified_async(self, view):
if view.settings().get("dired_preview_view"):
view.settings().erase("dired_preview_view")
def on_pre_close(self, view):
window = view.window()
if (
window
and view.settings().get("dired_close_empty_preview_pane")
):
group, _ = window.get_view_index(view)
preview_to_get_closed[view.id()] = (window, group)
def on_close(self, view):
window, group = preview_to_get_closed.pop(view.id(), (None, None))
if (window, group) == (None, None):
return
if window.num_groups() == 2 and len(window.views_in_group(group)) == 0:
window.set_layout({"cols": [0.0, 1.0], "rows": [0.0, 1.0], "cells": [[0, 0, 1, 1]]})
class DiredExpand(TextCommand, DiredBaseCommand):
'''Open directory(s) inline, aka treeview'''
def run(self, edit, toggle=False):
'''
toggle if True, state of directory(s) will be toggled (i.e. expand/collapse)
'''
self.index = self.get_all()
items = self.get_marked(full=True) or self.get_selected(parent=False, full=True)
paths = [path for path in items if path.endswith(os.sep)]
if len(paths) == 1:
return self.expand_single_directory(edit, paths[0], toggle)
elif paths:
# working with several selections at once is very tricky, thus for reliability we should
# recreate the entire tree, despite it is supposedly slower, but not really, because
# one view.replace/insert() call is faster than multiple ones
self.view.run_command('dired_refresh', {'to_expand': paths, 'toggle': toggle})
return
else:
return sublime.status_message('Item cannot be expanded')
# def expand_single_directory(self, edit, path, toggle):
# '''Expand one directory is save and fast, thus we do it here,
# but for many directories calling refresh command'''
# marked = self.get_marked()
# seled = self.get_selected()
# self.sel = self.view.get_regions('marked')[0] if marked else list(self.view.sel())[0]
# line = self.view.line(self.sel)
# line_content = self.view.substr(line)
# if line_content.lstrip().startswith('▾'):
# if toggle:
# self.view.run_command('dired_fold')
# return
# # number of next line to make slicing work properly
# self.number_line = 1 + self.view.rowcol(line.a)[0]
# # line may have inline error msg after os.sep
# # Replace both the arrow and the folder icon
# collapsed_icon = ICONS['folder_collapsed']
# expanded_icon = ICONS['folder_expanded']
# # Replace arrow and icon
# if collapsed_icon in line_content:
# parts = line_content.split(collapsed_icon)
# if len(parts) > 1:
# prefix = parts[0].replace('▸', '▾')
# rest = parts[1]
# root = prefix + expanded_icon + rest
# else:
# root = line_content.split(os.sep)[0].replace('▸', '▾', 1) + os.sep
# else:
# root = line_content.split(os.sep)[0].replace('▸', '▾', 1) + os.sep
# self.show_hidden = self.view.settings().get('dired_show_hidden_files', True)
# items, error = self.try_listing_directory(path)
# if error:
# replacement = ['%s\t<%s>' % (root, error)]
# elif items:
# replacement = [root] + self.prepare_filelist(items, '', path, '\t')
# dired_count = self.view.settings().get('dired_count', 0)
# self.view.settings().set('dired_count', dired_count + len(items))
# else: # expanding empty folder, so notify that it is empty
# replacement = ['%s\t<empty>' % root]
# self.view.set_read_only(False)
# self.view.replace(edit, line, '\n'.join(replacement))
# self.view.set_read_only(True)
# self.view.settings().set('dired_index', self.index)
# self.restore_marks(marked)
# self.restore_sels((seled, [self.sel]))
# self.view.run_command("dired_draw_vcs_marker")
# emit_event('add_paths', (self.view.id(), [path]), view=self.view)
def expand_single_directory(self, edit, path, toggle):
'''Expand one directory is save and fast, thus we do it here,
but for many directories calling refresh command'''
marked = self.get_marked()
seled = self.get_selected()
self.sel = self.view.get_regions('marked')[0] if marked else list(self.view.sel())[0]
line = self.view.line(self.sel)
line_content = self.view.substr(line)
if line_content.lstrip().startswith('▾'):
if toggle:
self.view.run_command('dired_fold')
return
# number of next line to make slicing work properly
self.number_line = 1 + self.view.rowcol(line.a)[0]
# line may have inline error msg after os.sep
# Replace both the arrow and the folder icon
collapsed_icon = ICONS['folder_collapsed']
expanded_icon = ICONS['folder_expanded']
# Replace arrow and icon
if collapsed_icon in line_content:
parts = line_content.split(collapsed_icon)
if len(parts) > 1:
prefix = parts[0].replace('▸', '▾')
rest = parts[1]
root = prefix + expanded_icon + rest
else:
root = line_content.split(os.sep)[0].replace('▸', '▾', 1) + os.sep
else:
root = line_content.split(os.sep)[0].replace('▸', '▾', 1) + os.sep
self.show_hidden = self.view.settings().get('dired_show_hidden_files', True)
items, error = self.try_listing_directory(path)
if error:
replacement = ['%s\t<%s>' % (root, error)]
elif items:
replacement = [root] + self.prepare_filelist(items, '', path, '\t')
dired_count = self.view.settings().get('dired_count', 0)
self.view.settings().set('dired_count', dired_count + len(items))
else: # expanding empty folder, so notify that it is empty
replacement = ['%s\t<empty>' % root]
self.view.set_read_only(False)
self.view.replace(edit, line, '\n'.join(replacement))
self.view.set_read_only(True)
self.view.settings().set('dired_index', self.index)
self.restore_marks(marked)
self.restore_sels((seled, [self.sel]))
self.view.run_command("dired_draw_vcs_marker")
emit_event('add_paths', (self.view.id(), [path]), view=self.view)
class DiredFold(TextCommand, DiredBaseCommand):
'''
This command used to fold/erase/shrink (whatever you like to call it) content
of some [sub]directory (within current directory, see self.path).
There are two cases when this command would be fired:
1. User mean to collapse (key ←)
2. User mean to expand (key →)
In first case we just erase region, however, we need to figure out which region to erase:
(a) if cursor placed on directory item and next line(s) indented (representing content of
the directory) — erase indented line(s);
(b) next line is not indented, but the line of directory item is indented — erase directory
item itself and all neighbours with the same indent;
(c) cursor placed on file item which is indented — same as prev. (erase item and neighbours)
In second case we need to decide if erasing needed or not:
(a) if directory was expanded — do erase (as in 1.a), so then it’ll be filled again,
basically it is like update/refresh;
(b) directory was collapsed — do nothing.
Very important, in case of actual modification of view, set valid dired_index setting
see DiredRefreshCommand docs for details
'''
def run(self, edit, update=None, index=None):
'''
update
True when user mean to expand, i.e. no folding for collapsed directory even if indented
index
list returned by self.get_all(), kinda cache during DiredExpand.expand_single_directory
Call self.fold method on each line (multiple selections/marks), restore marks and selections
'''
v = self.view
self.update = update
self.index = index or self.get_all()
self.marked = None
self.seled = (self.get_selected(), list(self.view.sel()))
marks = self.view.get_regions('marked')
virt_sels = []
if marks:
for m in marks:
if 'directory' in self.view.scope_name(m.a):
virt_sels.append(Region(m.a, m.a))
self.marked = self.get_marked()
sels = virt_sels or list(v.sel())
lines = [v.line(s.a) for s in reversed(sels)]
for line in lines:
self.fold(edit, line)
self.restore_marks(self.marked)
self.restore_sels(self.seled)
self.view.run_command("dired_draw_vcs_marker")
def fold(self, edit, line):
'''line is a Region, on which folding is supposed to happen (or not)'''
line, indented_region = self.get_indented_region(line)
if not indented_region:
return # folding is not supposed to happen, so we exit
self.apply_change_into_view(edit, line, indented_region)
def get_indented_region(self, line):
'''Return tuple:
line
Region which shall NOT be erased, can be equal to argument line or less if folding
was called on indented file item or indented collapsed directory
indented_region
Region which shall be erased
'''
v = self.view
eol = line.b - 1
if 'error' in v.scope_name(eol): # remove inline error, e.g. <empty>
indented_region = v.extract_scope(eol)
return (line, indented_region)
current_region = v.indented_region(line.b)
next_region = v.indented_region(line.b + 2)
is_dir = 'directory' in v.scope_name(line.a)
next_empty = next_region.empty()
this_empty = current_region.empty()
line_in_next = next_region.contains(line)
this_in_next = next_region.contains(current_region)
def __should_exit():
collapsed_dir = self.update and (line_in_next or next_empty or this_in_next)
item_in_root = (not is_dir or next_empty) and this_empty
return collapsed_dir or item_in_root
if __should_exit():
return (None, None)
elif self.update or (is_dir and not next_empty and not line_in_next):
indented_region = next_region
elif not this_empty:
indented_region = current_region
line = v.line(indented_region.a - 2)
else:
return (None, None)
return (line, indented_region)
# ------------- ORIGINAL CODE -----------------------------
# def apply_change_into_view(self, edit, line, indented_region):
# '''set count and index, track marks/selections, replace icon, erase indented_region'''
# v = self.view
# start_line = 1 + v.rowcol(line.a)[0]
# # do not set count & index on empty directory
# if not line.contains(indented_region):
# removed_count = len(v.lines(indented_region))
# dired_count = v.settings().get('dired_count', 0)
# v.settings().set('dired_count', int(dired_count) - removed_count)
# if indented_region.b == v.size():
# # MUST avoid new line at eof
# indented_region = Region(indented_region.a - 1, indented_region.b)
# end_line = start_line + removed_count
# self.index = self.index[:start_line] + self.index[end_line:]
# v.settings().set('dired_index', self.index)
# if self.marked or self.seled:
# path = self.path
# folded_name = self.get_parent(line, path)
# if self.marked:
# self.marked.append(folded_name)
# elif self.seled:
# self.seled[0].append(folded_name)
# name_point = self._get_name_point(line)
# icon_region = Region(name_point - 2, name_point - 1)
# v.set_read_only(False)
# v.replace(edit, icon_region, '▸')
# v.erase(edit, indented_region)
# v.set_read_only(True)
# emit_event('remove_path', (self.view.id(), self.index[start_line - 1]), view=self.view)
# 4. Update the apply_change_into_view method in DiredFold class
# to preserve icons when changing the arrow
# ------------- WORKING CODE(21.2.2025) -----------------------------!!!!!!!!!!!!!!!!!!
def apply_change_into_view(self, edit, line, indented_region):
'''set count and index, track marks/selections, replace icon, erase indented_region'''
v = self.view
start_line = 1 + v.rowcol(line.a)[0]
# do not set count & index on empty directory
if not line.contains(indented_region):
removed_count = len(v.lines(indented_region))
dired_count = v.settings().get('dired_count', 0)
v.settings().set('dired_count', int(dired_count) - removed_count)
if indented_region.b == v.size():
# MUST avoid new line at eof
indented_region = Region(indented_region.a - 1, indented_region.b)
end_line = start_line + removed_count
self.index = self.index[:start_line] + self.index[end_line:]
v.settings().set('dired_index', self.index)
if self.marked or self.seled:
path = self.path
folded_name = self.get_parent(line, path)
if self.marked:
self.marked.append(folded_name)
elif self.seled:
if isinstance(self.seled[0], list):
self.seled[0].append(folded_name)
else:
try:
self.seled = (list(self.seled[0]) + [folded_name], self.seled[1])
except (TypeError, ValueError):
self.seled = ([folded_name], self.seled[1])
# Find and replace the arrow and folder icon
line_content = v.substr(line)
expanded_icon = ICONS['folder_expanded']
collapsed_icon = ICONS['folder_collapsed']
if '▾' in line_content and expanded_icon in line_content:
# Replace both the arrow and the folder icon
parts = line_content.split(expanded_icon)
if len(parts) > 1:
prefix = parts[0].replace('▾', '▸')
rest = parts[1]
v.set_read_only(False)
v.replace(edit, line, prefix + collapsed_icon + rest)
v.erase(edit, indented_region)
v.set_read_only(True)
else:
# Fallback to simple replacement
arrow_pos = line_content.find('▾')
if arrow_pos != -1:
icon_region = Region(line.a + arrow_pos, line.a + arrow_pos + 1)
v.set_read_only(False)
v.replace(edit, icon_region, '▸')
v.erase(edit, indented_region)
v.set_read_only(True)
else:
# Fallback to original method
name_point = self._get_name_point(line)
icon_region = Region(name_point - 2, name_point - 1)
v.set_read_only(False)
v.replace(edit, icon_region, '▸')
v.erase(edit, indented_region)
v.set_read_only(True)
emit_event('remove_path', (self.view.id(), self.index[start_line - 1]), view=self.view)
class DiredUpCommand(TextCommand, DiredBaseCommand):
def run(self, edit):
path = self.path
if path == 'ThisPC\\':
self.view.run_command('dired_refresh')
return
parent = dirname(path.rstrip(os.sep))
if not parent.endswith(os.sep):
parent += os.sep
if parent == path:
if NT:
parent = 'ThisPC\\'
else:
return
view_id = (self.view.id() if reuse_view() else None)
goto = basename(path.rstrip(os.sep)) or path
show(self.view.window(), parent, view_id, goto=goto)
class DiredGotoCommand(TextCommand, DiredBaseCommand):
"""
Prompt for a new directory.
"""
def run(self, edit):
prompt.start('Goto:', self.view.window(), self.path, self.goto)
def goto(self, path):
show(self.view.window(), path, view_id=self.view.id())
# MARKING ###########################################################
class DiredMarkExtensionCommand(TextCommand, DiredBaseCommand):
def run(self, edit):
filergn = self.fileregion()
if filergn.empty():
return
current_item = self.view.substr(self.view.line(self.view.sel()[0].a))
if current_item.endswith(os.sep) or current_item == PARENT_SYM:
ext = ''
else:
ext = current_item.split('.')[-1]
pv = self.view.window().show_input_panel('Extension:', ext, self.on_done, None, None)
pv.run_command("select_all")
def on_done(self, ext):
ext = ext.strip()
if not ext:
return
if not ext.startswith('.'):
ext = '.' + ext
self._mark(mark=lambda oldmark, filename: filename.endswith(ext) or oldmark,
regions=[self.fileregion()])
class DiredMarkCommand(TextCommand, DiredBaseCommand):
"""
Marks or unmarks files.
The mark can be set to '*' to mark a file, ' ' to unmark a file, or 't' to toggle the
mark.
By default only selected files are marked, but if markall is True all files are
marked/unmarked and the selection is ignored.
If there is no selection and mark is '*', the cursor is moved to the next line so
successive files can be marked by repeating the mark key binding (e.g. 'm').
"""
def run(self, edit, mark=True, markall=False, forward=True):
assert mark in (True, False, 'toggle')
filergn = self.fileregion()
if filergn.empty():
return
if not mark and markall:
self.view.erase_regions('marked')
return
# If markall is set, mark/unmark all files. Otherwise only those that are selected.
regions = [filergn] if markall else list(self.view.sel())
if mark == 'toggle':
mark = lambda oldmark, filename: not oldmark
self._mark(mark=mark, regions=regions)
# If there is no selection, move the cursor forward so the user can keep pressing 'm'
# to mark successive files.
if not markall and len(self.view.sel()) == 1 and self.view.sel()[0].empty():
self.move(forward)
# OTHER #############################################################
class DiredToggleHiddenFilesCommand(TextCommand):
def run(self, edit):
show = self.view.settings().get('dired_show_hidden_files', True)
self.view.settings().set('dired_show_hidden_files', not show)
self.view.run_command('dired_refresh')
# MOUSE INTERACTIONS #################################################
class DiredDoubleclickCommand(TextCommand, DiredBaseCommand):
def run_(self, _, args):
view = self.view
s = view.settings()
if s.get("dired_path") and not s.get("dired_rename_mode"):
if 'directory' in view.scope_name(view.sel()[0].a):
command = ("dired_expand", {"toggle": True})
else:
command = ("dired_select", {"other_group": True})
view.run_command(*command)
else:
system_command = args["command"] if "command" in args else None
if system_command:
system_args = dict({"event": args["event"]}.items())
system_args.update(dict(args["args"].items()))
view.run_command(system_command, system_args)
# coding: utf8
'''This module contains all commands for operations with files:
create, delete, rename, copy, and move
'''
import re
import os
import shutil
import tempfile
import itertools
import threading
from os.path import basename, dirname, isdir, isfile, exists, join
import sublime
from sublime import Region
from sublime_plugin import TextCommand
from .common import (
DiredBaseCommand, relative_path, emit_event,
NT, PARENT_SYM, MARK_OPTIONS)
from . import prompt
if NT:
import ctypes
from Default.send2trash.plat_win import SHFILEOPSTRUCTW
try:
import Default.send2trash as send2trash
except ImportError:
send2trash = None
class DiredCreateCommand(TextCommand, DiredBaseCommand):
def run(self, edit, which=None):
assert which in ('file', 'directory'), "which: " + which
emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
self.index = self.get_all()
rel_path = relative_path(self.get_selected(parent=False) or '')
self.which = which
self.refresh = True
pv = self.view.window().show_input_panel(
which.capitalize() + ':', rel_path, self.on_done, None, None)
pv.run_command('move_to', {'to': 'eol', 'extend': False})
pv.settings().set('dired_create', True)
pv.settings().set('which', which)
pv.settings().set('dired_path', self.path)
def on_done(self, value):
value = value.strip()
if not value:
return False
fqn = join(self.path, value)
if exists(fqn):
sublime.error_message('{0} already exists'.format(fqn))
return False
if self.which == 'directory':
os.makedirs(fqn)
else:
with open(fqn, 'wb'):
pass
if self.refresh: # user press enter
emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
self.view.run_command('dired_refresh', {'goto': fqn})
# user press ctrl+enter, no refresh
return fqn
class DiredCreateAndOpenCommand(DiredCreateCommand):
'''Being called with ctrl+enter while user is in Create prompt
So self.view is prompt view
'''
def run(self, edit):
self.which = self.view.settings().get('which', '')
if not self.which:
return sublime.error_message('oops, does not work!')
self.refresh = False
value = self.view.substr(Region(0, self.view.size()))
fqn = self.on_done(value)
if not fqn:
return sublime.status_message('oops, does not work!')
sublime.active_window().run_command('hide_panel', {'cancel': True})
dired_view = sublime.active_window().active_view()
if dired_view.settings().has('dired_path'):
self.refresh = True
if self.which == 'directory':
dired_view.settings().set('dired_path', fqn + os.sep)
else:
sublime.active_window().open_file(fqn)
if self.refresh:
emit_event('watch_view', dired_view.id(), plugin='FileBrowserWFS')
dired_view.run_command('dired_refresh', {'goto': fqn})
class DiredDeleteCommand(TextCommand, DiredBaseCommand):
def run(self, edit, trash=False):
self.index = self.get_all()
files = self.get_marked() or self.get_selected(parent=False)
if not files:
return sublime.status_message('Nothing chosen')
msg, trash = self.setup_msg(files, trash)
emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
if trash:
need_confirm = self.view.settings().get('dired_confirm_send2trash', True)
msg = msg.replace('Delete', 'Delete to trash', 1)
if not need_confirm or (need_confirm and sublime.ok_cancel_dialog(msg)):
self._to_trash(files)
elif not trash and sublime.ok_cancel_dialog(msg):
self._delete(files)
else:
print("Cancel delete or something wrong in DiredDeleteCommand")
emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
def setup_msg(self, files, trash):
'''If user send to trash, but send2trash is unavailable, we suggest deleting permanently'''
# Yes, I know this is English. Not sure how Sublime is translating.
if len(files) == 1:
msg = "Delete {0}?".format(files[0])
else:
msg = "Delete {0} items?".format(len(files))
if trash and not send2trash:
msg = "Cannot delete to trash.\nPermanently " + msg.replace('D', 'd', 1)
trash = False
return (msg, trash)
def _to_trash(self, files):
'''Sending to trash might be slow
So we start two threads which run _sender function in parallel and each of them waiting and
setting certain event:
1. remove one which signals that _sender need try to call send2trash for current file
and if it fails collect error message (always ascii, no bother with encoding)
2. report one which call _status function to display message on status-bar so user
can see what is going on
When loop in _sender is finished we refresh view and show errors if any.
On Windows we call API directly in call_SHFileOperationW class.
'''
path = self.path
if NT: # use winapi directly, because better errors handling, plus GUI
sources_move = [join(path, f) for f in files]
return call_SHFileOperationW(self.view, sources_move, [], '$TRASH$')
errors = []
def _status(filename='', done=False):
if done:
sublime.set_timeout(lambda: self.view.run_command('dired_refresh'), 1)
if errors:
sublime.error_message(
'Some files couldn’t be sent to trash '
'(perhaps, they are being used by another process): \n\n'
+ '\n'.join(errors).replace('Couldn\'t perform operation.', '')
)
else:
status = 'Please, wait… Removing ' + filename
sublime.set_timeout(lambda: self.view.set_status("__FileBrowser__", status), 1)
def _sender(files, event_for_wait, event_for_set):
for filename in files:
event_for_wait.wait()
event_for_wait.clear()
if event_for_wait is remove_event:
try:
send2trash.send2trash(join(path, filename))
except OSError as e:
errors.append('{0}:\t{1}'.format(e, filename))
else:
_status(filename)
event_for_set.set()
if event_for_wait is remove_event:
_status(done=True)
remove_event = threading.Event()
report_event = threading.Event()
t1 = threading.Thread(target=_sender, args=(files, remove_event, report_event))
t2 = threading.Thread(target=_sender, args=(files, report_event, remove_event))
t1.start()
t2.start()
report_event.set()
def _delete(self, files):
'''Delete is fast, no need to bother with threads or even status message
But need to bother with encoding of error messages since they are vary,
depending on OS and/or version of Python
'''
errors = []
for filename in files:
fqn = join(self.path, filename)
try:
if isdir(fqn):
shutil.rmtree(fqn)
else:
os.remove(fqn)
except (PermissionError, FileNotFoundError) as e:
e = str(e).split(':')[0].replace('[Error 5] ', 'Access denied')
errors.append('{0}:\t{1}'.format(e, filename))
self.view.run_command('dired_refresh')
if errors:
sublime.error_message('Some files couldn’t be deleted: \n\n' + '\n'.join(errors))
# -------------- ORIGINAL CODE -------------------
# class DiredRenameCommand(TextCommand, DiredBaseCommand):
# def run(self, edit):
# if not self.filecount():
# return sublime.status_message('Directory seems empty, nothing could be renamed')
# emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
# # Store the original filenames so we can compare later.
# path = self.path
# self.view.settings().set(
# 'rename',
# [
# f
# for f in self.get_all_relative('' if path == 'ThisPC\\' else path)
# if f and f != PARENT_SYM
# ]
# )
# self.view.settings().set('dired_rename_mode', True)
# self.view.set_read_only(False)
# self.set_ui_in_rename_mode(edit)
# self.view.set_status(
# "__FileBrowser__",
# " 𝌆 [enter: Apply changes] [escape: Discard changes]"
# + (
# ' ¡¡¡DO NOT RENAME DISKS!!! you can rename their children though'
# if path == 'ThisPC\\' else ''
# )
# )
# # Mark the original filename lines so we can make sure they are in the same place.
# r = self.fileregion()
# self.view.add_regions('rename', [r], '', '', MARK_OPTIONS)
class DiredRenameCommand(TextCommand, DiredBaseCommand):
def run(self, edit):
if not self.filecount():
return sublime.status_message('Directory seems empty, nothing could be renamed')
emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
# Store the original filenames so we can compare later.
path = self.path
self.view.settings().set(
'rename',
[
f
for f in self.get_all_relative('' if path == 'ThisPC\\' else path)
if f and f != PARENT_SYM
]
)
self.view.settings().set('dired_rename_mode', True)
self.view.set_read_only(False)
self.set_ui_in_rename_mode(edit)
self.view.set_status(
"__FileBrowser__",
" 𝌆 [enter: Apply changes] [escape: Discard changes]"
+ (
' ¡¡¡DO NOT RENAME DISKS!!! you can rename their children though'
if path == 'ThisPC\\' else ''
)
)
# Mark the original filename lines so we can make sure they are in the same place.
r = self.fileregion()
self.view.add_regions('rename', [r], '', '', MARK_OPTIONS)
class DiredRenameCancelCommand(TextCommand, DiredBaseCommand):
"""Cancel rename mode"""
def run(self, edit):
# Remember which directories were expanded
expanded_dirs = [r.a for r in self.view.find_all(r'^\s*▾')]
expanded_paths = []
for region in expanded_dirs:
line = self.view.line(region)
try:
path = self.get_fullpath_for(line)
expanded_paths.append(path)
except:
pass
# Make sure we're watching the view again
emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
# Clear rename-related settings
self.view.settings().erase('rename')
self.view.settings().set('dired_rename_mode', False)
self.view.erase_regions('rename')
# Refresh the view to restore the original state with icons
self.view.run_command('dired_refresh', {'to_expand': expanded_paths})
class DiredRenameCommitCommand(TextCommand, DiredBaseCommand):
# --------------- ORIGINAL CODE ----------------
# def run(self, edit):
# if not self.view.settings().has('rename'):
# # Shouldn't happen, but we want to cleanup when things go wrong.
# self.view.run_command('dired_refresh')
# return
# before = self.view.settings().get('rename')
# # We marked the set of files with a region. Make sure the region still has the same
# # number of files.
# after = self.get_after()
# if len(after) != len(before):
# return sublime.error_message('You cannot add or remove lines')
# if len(set(after)) != len(after):
# return self.report_conflicts(before, after)
# self.apply_renames(before, after)
# self.view.erase_regions('rename')
# self.view.settings().erase('rename')
# self.view.settings().set('dired_rename_mode', False)
# emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
# self.view.run_command('dired_refresh', {'to_expand': self.re_expand_new_names()})
def run(self, edit):
print("DiredRenameCommitCommand run method called")
if not self.view.settings().has('rename'):
# Shouldn't happen, but we want to cleanup when things go wrong.
self.view.run_command('dired_refresh')
return
before = self.view.settings().get('rename')
after = self.get_after()
print(f"Before: {before}")
print(f"After: {after}")
if len(after) != len(before):
return sublime.error_message('You cannot add or remove lines')
if len(set(after)) != len(after):
return self.report_conflicts(before, after)
# Remember which directories were expanded
expanded_dirs = [r.a for r in self.view.find_all(r'^\s*▾')]
expanded_paths = []
for region in expanded_dirs:
line = self.view.line(region)
path = self.get_fullpath_for(line)
expanded_paths.append(path)
# Apply the renames
self.apply_renames(before, after)
# Clean up rename mode settings
self.view.erase_regions('rename')
self.view.settings().erase('rename')
self.view.settings().set('dired_rename_mode', False)
# Make sure to set read-only back to True
self.view.set_read_only(True)
# Now emit the watch event for the view
emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
# Complete refresh of the view with the expanded dirs
to_expand = self.re_expand_new_names() or expanded_paths
self.view.run_command('dired_refresh', {'to_expand': to_expand})
# --------------- ORIGINAL CODE ----------------
# def get_after(self):
# '''Return list of all filenames in the view'''
# self.index = self.get_all()
# path = self.path
# lines = self._get_lines(self.view.get_regions('rename'), self.fileregion())
# return [self._new_name(line, path=path) for line in lines]
def get_after(self):
'''Return list of all filenames in the view'''
self.index = self.get_all()
path = self.path
lines = self._get_lines(self.view.get_regions('rename'), self.fileregion())
return [self._new_name(line, path=path) for line in lines]
# MODIFINY THIS ORIGINAL CODE
# def _new_name(self, line, path=None, full=False):
# '''Return new name for line
# full
# if True return full path, otherwise relative to path variable
# path
# root, returning value is relative to it
# '''
# if full:
# parent = dirname(self.get_fullpath_for(line).rstrip(os.sep))
# else:
# parent = dirname(self.get_parent(line, path).rstrip(os.sep))
# new_name = self.view.substr(Region(self._get_name_point(line), line.b))
# if os.sep in new_name: # ignore trailing errors, e.g. <empty>
# new_name = new_name.split(os.sep)[0] + os.sep
# return join(parent, new_name)
def _new_name(self, line, path=None, full=False):
'''Return new name for line'''
# Get the parent directory path
if full:
parent = dirname(self.get_fullpath_for(line).rstrip(os.sep))
else:
parent = dirname(self.get_parent(line, path).rstrip(os.sep))
# Get the filename from the line
name_point = self._get_name_point(line)
text = self.view.substr(Region(name_point, line.b))
# Remove any Nerd Font icons (non-ASCII characters)
cleaned_text = re.sub(r'[^\x20-\x7E]', '', text).strip()
# Handle directory separator if present
is_dir = self.get_fullpath_for(line).endswith(os.sep)
# Create the cleaned name
new_name = cleaned_text
if is_dir and not new_name.endswith(os.sep):
new_name += os.sep
# Join with parent path
return join(parent, new_name)
def report_conflicts(self, before, after):
'''
before list of all filenames before enter rename mode
after list of all filenames upon commit rename
Warn about conflicts and print original (before) and conflicting (after) names for
each item which cause conflict.
'''
sublime.error_message('There are duplicate filenames (see details in console)')
self.view.window().run_command("show_panel", {"panel": "console"})
print(
*(
'\n Original name: {0}\nConflicting name: {1}'.format(b, a)
for (b, a) in zip(before, after) if b != a and a in before
),
sep='\n',
end='\n\n'
)
print('You can either resolve conflicts and apply changes or cancel renaming.\n')
def apply_renames(self, before, after):
'''args are the same as in self.report_conflicts
If before and after differ, try to do actual rename for each pair
Take into account directory symlinks on Unix-like OSes (they cannot be just renamed)
In case of error, show message and exit skipping remain pairs
'''
# reverse order to allow renaming child and parent at the same time (tree view)
diffs = list(reversed([(b, a) for (b, a) in zip(before, after) if b != a]))
if not diffs:
return sublime.status_message('Exit rename mode, no changes')
path = self.path
existing = set(before)
window = self.view.window()
def retarget(a, b):
if not window:
return
view = window.find_open_file(a)
if view:
view.retarget(b)
while diffs:
b, a = diffs.pop(0)
if a in existing:
# There is already a file with this name. Give it a temporary name (in
# case of cycles like "x->z and z->x") and put it back on the list.
tmp = tempfile.NamedTemporaryFile(delete=False, dir=path).name
os.unlink(tmp)
diffs.append((tmp, a))
a = tmp
print('FileBrowser rename: {0} → {1}'.format(b, a))
orig = join(path, b)
if orig[~0] == '/' and os.path.islink(orig[:~0]):
# last slash shall be omitted; file has no last slash,
# thus it False and symlink to file shall be os.rename'd
dest = os.readlink(orig[:~0])
os.unlink(orig[:~0])
os.symlink(dest, join(path, a)[:~0])
else:
try:
os.rename(orig, join(path, a))
except OSError:
msg = (
'FileBrowser:\n\nError is occurred during renaming.\n'
'Please, fix it and apply changes or cancel renaming.\n\n'
'\t {0} → {1}\n\n'
'Don’t rename\n'
' • non-existed file (cancel renaming to refresh)\n'
' • file if you’re not owner'
' • disk letter on Windows\n'.format(b, a)
)
sublime.error_message(msg)
return
retarget(orig, join(path, a))
existing.remove(b)
existing.add(a)
def re_expand_new_names(self):
'''Make sure that expanded directories will keep state if were renamed'''
expanded = [self.view.line(r) for r in self.view.find_all(r'^\s*▾')]
return [self._new_name(line, full=True) for line in expanded]
class DiredCopyFilesCommand(TextCommand, DiredBaseCommand):
'''Store filename(s) in settings, when user copy or cut'''
def run(self, edit, cut=False):
self.index = self.get_all()
filenames = self.get_marked(full=True) or self.get_selected(parent=False, full=True)
if not filenames:
return sublime.status_message('Nothing chosen')
settings = sublime.load_settings('dired.sublime-settings')
copy_list = settings.get('dired_to_copy', [])
cut_list = settings.get('dired_to_move', [])
# copied item shall not be added into cut list, and vice versa
for f in filenames:
if cut:
if f not in copy_list:
cut_list.append(f)
else:
if f not in cut_list:
copy_list.append(f)
settings.set('dired_to_move', list(set(cut_list)))
settings.set('dired_to_copy', list(set(copy_list)))
sublime.save_settings('dired.sublime-settings')
self.show_hidden = self.view.settings().get('dired_show_hidden_files', True)
self.set_status()
class DiredPasteFilesCommand(TextCommand, DiredBaseCommand):
'''Init file operation(s) for stored names in settings, when user paste'''
def run(self, edit):
s = self.view.settings()
sources_move = s.get('dired_to_move', [])
sources_copy = s.get('dired_to_copy', [])
if not (sources_move or sources_copy):
return sublime.status_message('Nothing to paste')
self.index = self.get_all()
path = self.get_path()
rel_path = relative_path(self.get_selected(parent=False) or '')
destination = join(path, rel_path) or path
emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
if NT:
return call_SHFileOperationW(self.view, sources_move, sources_copy, destination)
else:
return call_SystemAgnosticFileOperation(
self.view, sources_move, sources_copy, destination)
class DiredPasteFilesToCommand(TextCommand, DiredBaseCommand):
'''Init prompt for path where to paste, then init file ops.'''
def run(self, edit):
s = self.view.settings()
self.index = self.get_all()
sources_move = s.get('dired_to_move', [])
sources_copy = (
s.get('dired_to_copy')
or self.get_marked(full=True)
or self.get_selected(parent=False, full=True)
)
mitems = len(sources_move)
citems = len(sources_copy)
if not (mitems or citems):
return sublime.status_message('Nothing to paste')
both = mitems and citems
msg = '%s%s to:' % (('Move %d' % mitems) if mitems else '',
('%sopy %d' % (' and c' if both else 'C', citems)) if citems else '')
path = self.get_path()
window = self.view.window() or sublime.active_window()
emit_event('ignore_view', self.view.id(), plugin='FileBrowserWFS')
prompt.start(msg, window, path, self.initfo, sources_move, sources_copy)
def initfo(self, destination, move, copy):
if NT:
return call_SHFileOperationW(self.view, move, copy, destination)
else:
return call_SystemAgnosticFileOperation(self.view, move, copy, destination)
class DiredClearCopyCutList(TextCommand):
def run(self, edit):
sublime.load_settings('dired.sublime-settings').set('dired_to_move', [])
sublime.load_settings('dired.sublime-settings').set('dired_to_copy', [])
sublime.save_settings('dired.sublime-settings')
self.view.run_command('dired_refresh')
def _dups(sources_copy, destination):
'''return list of files that should be duplicated silently'''
return [
p
for p in sources_copy
if os.path.split(p.rstrip(os.sep))[0] == destination.rstrip(os.sep)
]
class call_SHFileOperationW(object):
'''call Windows API for file operations'''
def __init__(self, view, sources_move, sources_copy, destination):
self.view = view
if destination == '$TRASH$':
self.shfow_d_thread = threading.Thread(target=self.caller, args=(3, sources_move, ''))
self.shfow_d_thread.start()
return
if sources_move:
self.shfow_m_thread = threading.Thread(
target=self.caller, args=(1, sources_move, destination))
self.shfow_m_thread.start()
if sources_copy:
# if user paste files in the same folder where they are then
# it shall duplicate these files w/o asking anything
dups = _dups(sources_copy, destination)
if dups:
self.shfow_d_thread = threading.Thread(
target=self.caller, args=(2, dups, destination, True))
self.shfow_d_thread.start()
sources_copy = [p for p in sources_copy if p not in dups]
if sources_copy:
self.shfow_c_thread = threading.Thread(
target=self.caller, args=(2, sources_copy, destination))
self.shfow_c_thread.start()
else:
self.shfow_c_thread = threading.Thread(
target=self.caller, args=(2, sources_copy, destination))
self.shfow_c_thread.start()
def caller(self, mode, sources, destination, duplicate=False):
'''mode is int: 1 (move), 2 (copy), 3 (delete)'''
if duplicate:
fFlags = 8
elif mode == 3:
fFlags = 64 # send to recycle bin
else:
fFlags = 0
SHFileOperationW = ctypes.windll.shell32.SHFileOperationW
SHFileOperationW.argtypes = [ctypes.POINTER(SHFILEOPSTRUCTW)]
pFrom = '\x00'.join(sources) + '\x00'
pTo = ('%s\x00' % destination) if destination else None
wf = ctypes.WINFUNCTYPE(ctypes.wintypes.HWND)
get_hwnd = wf(ctypes.windll.user32.GetForegroundWindow)
args = SHFILEOPSTRUCTW(
hwnd = get_hwnd(),
wFunc = ctypes.wintypes.UINT(mode),
pFrom = ctypes.wintypes.LPCWSTR(pFrom),
pTo = ctypes.wintypes.LPCWSTR(pTo),
fFlags = fFlags,
fAnyOperationsAborted = ctypes.wintypes.BOOL()
)
out = SHFileOperationW(ctypes.byref(args))
sublime.set_timeout(
lambda: emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS'), 1)
if not out and destination: # 0 == success
sublime.set_timeout(lambda: self.view.run_command('dired_clear_copy_cut_list'), 1)
else: # probably user cancel op., or sth went wrong; keep settings
sublime.set_timeout(lambda: self.view.run_command('dired_refresh'), 1)
class call_SystemAgnosticFileOperation(object):
'''file operations using Python standard library'''
def __init__(self, view, sources_move, sources_copy, destination):
self.view = view
self.window = view.window()
self.threads = []
self.errors = {}
if sources_move:
self.caller('move', sources_move, destination)
if sources_copy:
# if user paste files in the same folder where they are then
# it shall duplicate these files w/o asking anything
dups = _dups(sources_copy, destination)
if dups:
self.caller('dup', dups, destination)
sources_copy = [p for p in sources_copy if p not in dups]
if sources_copy:
self.caller('copy', sources_copy, destination)
else:
self.caller('copy', sources_copy, destination)
self.check_errors()
self.start_threads()
def check_errors(self):
msg = (
'FileBrowser:\n\n'
'Some files exist already, Cancel to skip all, OK to overwrite or rename.\n\n'
'\t{0}'.format('\n\t'.join(self.errors.keys()))
)
self.actions = [['Overwrite', 'Folder cannot be overwritten'],
['Duplicate', 'Item will be renamed automatically']]
if self.errors and sublime.ok_cancel_dialog(msg):
self.show_quick_panel()
def start_threads(self):
if self.threads:
for t in self.threads:
t.start()
self.progress_bar(self.threads)
def show_quick_panel(self):
'''dialog asks if user would like to duplicate, overwrite, or skip item'''
t, f = self.errors.popitem()
options = self.actions + [['from %s' % f, 'Skip'], ['to %s' % t, 'Skip']]
done = lambda i: self.user_input(i, f, t)
sublime.set_timeout(
lambda: self.window.show_quick_panel(options, done, sublime.MONOSPACE_FONT),
10
)
return
def user_input(self, i, name, new_name):
if i == 0:
self._setup('overwrite', name, new_name)
if i == 1:
self.duplicate(name, new_name)
if self.errors:
self.show_quick_panel()
else:
self.start_threads()
def caller(self, mode, sources, destination):
for fqn in sources:
new_name = join(destination, basename(fqn.rstrip(os.sep)))
if mode == 'dup':
self.duplicate(fqn, new_name)
else:
self._setup(mode, fqn, new_name)
def duplicate(self, name, new_name):
new_name = self.generic_nn(new_name)
self._setup_copy(name, new_name, False)
def _setup(self, mode, original, new_name):
'''mode can be "move", "copy", "overwrite"'''
if mode == 'move' and original != dirname(new_name):
return self._setup_move(original, new_name)
overwrite = mode == 'overwrite'
if mode == 'copy' or overwrite:
return self._setup_copy(original, new_name, overwrite)
def _setup_move(self, original, new_name):
if not exists(new_name):
self._init_thread('move', original, new_name)
else:
self.errors.update({new_name: original})
def _setup_copy(self, original, new_name, overwrite):
'''overwrite is either True or False'''
def __dir_or_file(mode, new_name, overwrite):
exist = isdir(new_name) if mode == 'dir' else isfile(new_name)
if not exist or overwrite:
self._init_thread(mode, original, new_name)
else:
self.errors.update({new_name: original})
if isdir(original):
__dir_or_file('dir', new_name, overwrite)
else:
__dir_or_file('file', new_name, overwrite)
def _init_thread(self, mode, source_name, new_name):
'''mode can be "move", "dir", "file"'''
t = threading.Thread(target=self._do, args=(mode, source_name, new_name))
t.setName(new_name)
self.threads.append(t)
def _do(self, mode, source_name, new_name):
try:
if mode == 'move': shutil.move(source_name, new_name)
if mode == 'dir': shutil.copytree(source_name, new_name)
if mode == 'file': shutil.copy2(source_name, new_name)
except shutil.Error as e:
m = e.args[0]
if isinstance(m, list):
sublime.error_message(
'FileBrowser:\n\n{0}'.format('\n'.join([i[-1] for i in m]))
)
else:
sublime.error_message('FileBrowser:\n\n' + e)
except Exception as e: # just in case
sublime.error_message('FileBrowser:\n\n' + str([e]))
def progress_bar(self, threads, i=0, dir=1):
threads = [t for t in threads if t.is_alive()]
if threads:
# This animates a little activity indicator in the status area
before = i % 8
after = (7) - before
if not after: dir = -1
if not before: dir = 1
i += dir
self.view.set_status(
'__FileBrowser__',
'Please wait{0}…{1}Writing {2}'.format(
' ' * before,
' ' * after,
', '.join([t.name for t in threads])
)
)
sublime.set_timeout(lambda: self.progress_bar(threads, i, dir), 100)
return
else:
emit_event('watch_view', self.view.id(), plugin='FileBrowserWFS')
self.view.run_command('dired_clear_copy_cut_list')
def generic_nn(self, old_name):
path, name = os.path.split(old_name)
split_name = name.split('.')
no_extension = len(split_name) == 1 or isdir(old_name)
separator = self.view.settings().get('dired_dup_separator', ' — ')
for i in itertools.count(2):
if no_extension:
cfp = "{1}{2}{0}".format(i, old_name, separator)
else:
# leading space may cause problems, e.g.
# good: 'name — 2.ext'
# good: '— 2.ext'
# bad: ' — 2.ext'
fn, ext = '.'.join(split_name[:~0]), split_name[~0]
nn = '{0}{1}{2}.{3}'.format(fn, separator, i, ext)
cfp = join(path, nn.lstrip())
if not os.path.exists(cfp):
break
return cfp
#!/usr/bin/python
# -*- coding: utf-8 -*-
import os
from os.path import basename
from .common import first, set_proper_scheme, calc_width, get_group
SYNTAX_EXTENSION = '.sublime-syntax'
def set_active_group(window, view, other_group):
nag = window.active_group()
if other_group:
group = 0 if other_group == 'left' else 1
groups = window.num_groups()
if groups == 1:
width = calc_width(view)
cols = [0.0, width, 1.0] if other_group == 'left' else [0.0, 1-width, 1.0]
window.set_layout({
"cols": cols,
"rows": [0.0, 1.0],
"cells": [[0, 0, 1, 1], [1, 0, 2, 1]]
})
elif view:
group = get_group(groups, nag)
window.set_view_index(view, group, 0)
else:
group = nag
# when other_group is left, we need move all views to right except FB view
if nag == 0 and other_group == 'left' and group == 0:
for v in reversed(window.views_in_group(nag)[1:]):
window.set_view_index(v, 1, 0)
return (nag, group)
def set_view(view_id, window, ignore_existing, path, single_pane):
view = None
if view_id:
# The Goto command was used so the view is already known and its contents should be
# replaced with the new path.
view = first(window.views(), lambda v: v.id() == view_id)
if not view and not ignore_existing:
# See if a view for this path already exists.
same_path = lambda v: v.settings().get('dired_path') == path
# See if any reusable view exists in case of single_pane argument
any_path = lambda v: v.score_selector(0, "text.dired") > 0
view = first(window.views(), any_path if single_pane else same_path)
if not view:
view = window.new_file()
view.settings().add_on_change('color_scheme', lambda: set_proper_scheme(view))
view.set_syntax_file('Packages/FileBrowser/dired' + SYNTAX_EXTENSION)
view.set_scratch(True)
reset_sels = True
else:
reset_sels = path != view.settings().get('dired_path', '')
return (view, reset_sels)
def show(
window,
path,
view_id=None,
ignore_existing=False,
single_pane=False,
goto='',
other_group=''
):
"""
Determines the correct view to use, creating one if necessary, and prepares it.
"""
if other_group:
prev_focus = window.active_view()
# simulate 'toggle sidebar':
if prev_focus and 'dired' in prev_focus.scope_name(0):
window.run_command('close_file')
return
if not path.endswith(os.sep):
path += os.sep
view, reset_sels = set_view(view_id, window, ignore_existing, path, single_pane)
set_active_group(window, view, other_group)
if other_group and prev_focus:
window.focus_view(prev_focus)
if path == os.sep:
view_name = os.sep
else:
view_name = basename(path.rstrip(os.sep))
name = "𝌆 {0}".format(view_name)
view.set_name(name)
view.settings().set('dired_path', path)
view.settings().set('dired_rename_mode', False)
# forcibly shoot on_activated, because when view was created it didnot have any settings
window.show_quick_panel(['a', 'b'], None)
view.run_command('dired_refresh', {'goto': goto, 'reset_sels': reset_sels})
window.run_command('hide_overlay')
window.focus_view(view)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment