diff --git a/scripts-blender/addons/blender_svn/constants.py b/scripts-blender/addons/blender_svn/constants.py index c992e08e..6a51e26f 100644 --- a/scripts-blender/addons/blender_svn/constants.py +++ b/scripts-blender/addons/blender_svn/constants.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later # (c) 2022, Blender Foundation - Demeter Dzadik +import bpy from collections import OrderedDict SVN_STATUS_DATA = OrderedDict( @@ -94,7 +95,6 @@ SVN_STATUS_DATA = OrderedDict( ] ) - # Based on PySVN/svn/constants.py/STATUS_TYPE_LOOKUP. ENUM_SVN_STATUS = [ (status, status.title(), @@ -119,3 +119,15 @@ SVN_STATUS_CHAR_TO_NAME = { SVN_STATUS_NAME_TO_CHAR = {value: key for key, value in SVN_STATUS_CHAR_TO_NAME.items()} + +DEPTH_ENUM = bpy.props.EnumProperty( + name="Depth", + description="The depth to which this directory should be updated", # For non-directory entries, this property is not used. + items=[ + ('INFINITY', 'Recursive (Default)', "Updates will recursively pull in any files or subdirectories not already present", 'OUTLINER', 3), + ('IMMEDIATES', 'Non-Recursive', "Updates will pull in any files or subdirectories not already present; those subdirectories' will be left empty", 'PRESET', 2), + ('FILES', 'Files Only', "Updates will pull in any files not already present, but not subdirectories", 'FILE', 1), + ('EMPTY', 'Empty', "Updates will not pull in any files or subdirectories not already present", 'SELECT_SET', 0), + ], + default='INFINITY' + ) diff --git a/scripts-blender/addons/blender_svn/operators/simple_commands.py b/scripts-blender/addons/blender_svn/operators/simple_commands.py index 6ec997f3..1e900203 100644 --- a/scripts-blender/addons/blender_svn/operators/simple_commands.py +++ b/scripts-blender/addons/blender_svn/operators/simple_commands.py @@ -14,6 +14,7 @@ from send2trash import send2trash from ..threaded.execute_subprocess import execute_svn_command from ..threaded.background_process import Processes from ..util import get_addon_prefs, redraw_viewport +from ..constants import DEPTH_ENUM class SVN_Operator: @@ -181,7 +182,7 @@ class SVN_OT_update_single(May_Modifiy_Current_Blend, Operator): class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator): bl_idname = "svn.download_file_revision" bl_label = "Download Revision" - bl_description = "Download this revision of this file" + bl_description = "Download this revision of this file. Automatic updates will be disabled and need to be re-enabled manually" bl_options = {'INTERNAL'} missing_file_allowed = True @@ -211,6 +212,8 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator): return {'CANCELLED'} self.svn_download_file_revision(context, self.file_rel_path, self.revision) + prefs = get_addon_prefs(context) + prefs.do_auto_updates = False self.report( {'INFO'}, f"Checked out revision {self.revision} of {self.file_rel_path}" @@ -456,6 +459,53 @@ class SVN_OT_resolve_conflict(May_Modifiy_Current_Blend, Operator): file_entry.status = 'normal' +class SVN_OT_set_directory_depth(SVN_Operator_Single_File, Operator): + bl_idname = "svn.set_directory_depth" + bl_label = "Set Directory Depth" + bl_description = "Set update depth of this directory" + bl_options = {'INTERNAL'} + + depth: DEPTH_ENUM + popup: BoolProperty( + name="Popup", + description="Whether the operator should use a pop-up prompt", + default=False + ) + + @classmethod + def description(cls, context, properties): + desc = DEPTH_ENUM.keywords['description'] + items = DEPTH_ENUM.keywords['items'] + for identifier, name, item_desc, icon, num in items: + if identifier == properties.depth: + return desc + ":\n" + item_desc + + def invoke(self, context, event): + if self.popup: + return context.window_manager.invoke_props_dialog(self) + return self.execute(context) + + def draw(self, context): + layout = self.layout + layout.use_property_split=True + layout.use_property_decorate=False + layout.prop(self, 'depth') + + def execute(self, context): + file_entry = self.get_file(context) + + if not file_entry.is_dir: + self.report({'ERROR'}, "Active file entry is not a directory") + return {'CANCELLED'} + + file_entry.depth = self.depth + + # Depth info needs to be saved to file to make sure it doesn't get lost. + get_addon_prefs(context).save_repo_info_to_file() + + return {'FINISHED'} + + class SVN_OT_cleanup(SVN_Operator, Operator): bl_idname = "svn.cleanup" bl_label = "SVN Cleanup" @@ -469,8 +519,12 @@ class SVN_OT_cleanup(SVN_Operator, Operator): def execute(self, context: Context) -> Set[str]: repo = context.scene.svn.get_repo(context) + prefs = get_addon_prefs(context) repo.external_files.clear() + repo.external_files_active_index = -1 + # Load folder depth data from file. + prefs.load_repo_info_from_file() self.execute_svn_command(context, ["svn", "cleanup"]) repo.reload_svn_log(context) @@ -488,6 +542,17 @@ class SVN_OT_cleanup(SVN_Operator, Operator): return {"FINISHED"} +class SVN_OT_cancel_running_operation(Operator): + bl_idname = "svn.cancel" + bl_label = "SVN Cancel Operation" + bl_description = "Cancel ongoing commit/update operation" + bl_options = {'INTERNAL'} + + def execute(self, context): + Processes.kill('Commit') + Processes.kill('Update') + return {'FINISHED'} + registry = [ SVN_OT_update_single, SVN_OT_revert_and_update, @@ -499,5 +564,7 @@ registry = [ SVN_OT_trash_file, SVN_OT_remove_file, SVN_OT_resolve_conflict, + SVN_OT_set_directory_depth, SVN_OT_cleanup, + SVN_OT_cancel_running_operation, ] diff --git a/scripts-blender/addons/blender_svn/operators/svn_update.py b/scripts-blender/addons/blender_svn/operators/svn_update.py index 4be00342..aa491765 100644 --- a/scripts-blender/addons/blender_svn/operators/svn_update.py +++ b/scripts-blender/addons/blender_svn/operators/svn_update.py @@ -15,7 +15,7 @@ from ..util import get_addon_prefs class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator): bl_idname = "svn.update_all" bl_label = "SVN Update All" - bl_description = "Download all the latest updates from the remote repository" + bl_description = "Update entire repository" bl_options = {'INTERNAL'} revision: IntProperty( @@ -24,6 +24,16 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator): default=0 ) + @classmethod + def description(cls, context, properties): + if properties.revision == 0: + return "Update entire repository to the latest version" + text = f"Revert entire repository to r{properties.revision}" + prefs = get_addon_prefs(context) + if prefs.do_auto_updates: + text += ". This will disable auto-updates, which can be re-enabled again manually" + return text + @classmethod def poll(cls, context): if get_addon_prefs(context).is_busy: @@ -72,6 +82,10 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator): def execute(self, context: Context) -> Set[str]: Processes.stop('Status') + + prefs = get_addon_prefs(context) + prefs.do_auto_updates = False + if self.reload_file: current_file = context.scene.svn.get_repo(context).current_blend_file command = ["svn", "up", current_file.svn_path, "--accept", "postpone"] diff --git a/scripts-blender/addons/blender_svn/prefs.py b/scripts-blender/addons/blender_svn/prefs.py index 1078f4c8..2c6617eb 100644 --- a/scripts-blender/addons/blender_svn/prefs.py +++ b/scripts-blender/addons/blender_svn/prefs.py @@ -26,6 +26,12 @@ class SVN_addon_preferences(AddonPreferences): default=False ) + do_auto_updates: BoolProperty( + name="Auto-Update", + description="Automatically download updates as they appear on the remote", + default=False + ) + repositories: CollectionProperty(type=SVN_repository) def init_repo_list(self): @@ -110,6 +116,9 @@ class SVN_addon_preferences(AddonPreferences): def is_busy(self): return Processes.is_running('Commit', 'Update') + + ### Keeping a backup of repository data in blender_svn.txt. ### + ### This is important so it doesn't get lost on add=on disable. ### loading: BoolProperty( name="Loading", description="Disable the credential update callbacks while loading repo data to avoid infinite loops", @@ -120,11 +129,13 @@ class SVN_addon_preferences(AddonPreferences): saved_props = {'url', 'directory', 'name', 'username', 'password', 'display_name'} repo_data = {} - for repo in self['repositories']: - directory = repo.get('directory', '') + for repo_dict, repo in zip(self['repositories'], self.repositories): + directory = repo_dict.get('directory', '') repo_data[directory] = { - key: value for key, value in repo.to_dict().items() if key in saved_props} + key: value for key, value in repo_dict.to_dict().items() if key in saved_props} + + repo_data[directory]['custom_directory_depths'] = {dir_entry.svn_path: dir_entry.depth for dir_entry in repo.external_files if dir_entry.is_dir and dir_entry.depth != 'INFINITY'} filepath = Path(bpy.utils.user_resource('CONFIG')) / \ Path("blender_svn.txt") @@ -148,7 +159,14 @@ class SVN_addon_preferences(AddonPreferences): repo = self.repositories.add() repo.directory = directory for key, value in repo_data.items(): - setattr(repo, key, value) + setattr(repo, key, value) + + if 'custom_directory_depths' in repo_data: + for dir_path, depth in repo_data['custom_directory_depths'].items(): + dir_entry = repo.external_files.get(dir_path) + if not dir_entry: + dir_entry = repo.file_add(dir_path) + dir_entry.depth = depth finally: self.loading = False @@ -166,7 +184,6 @@ class SVN_addon_preferences(AddonPreferences): else: draw_repo_list(self, context) - def draw_prefs_no_svn(self, context): terminal, url = "terminal", "https://subversion.apache.org/packages.html" system = platform.system() diff --git a/scripts-blender/addons/blender_svn/repository.py b/scripts-blender/addons/blender_svn/repository.py index 218394d1..6b06691c 100644 --- a/scripts-blender/addons/blender_svn/repository.py +++ b/scripts-blender/addons/blender_svn/repository.py @@ -36,7 +36,9 @@ class SVN_file(PropertyGroup): @svn_path.setter def svn_path(self, value): - self.name = value + path = Path(value) + self.name = str(path.as_posix()) + self.tree_depth = len(path.parents)-1 @property def file_name(self): @@ -48,6 +50,13 @@ class SVN_file(PropertyGroup): options=set() ) + depth: constants.DEPTH_ENUM + @property + def depth_icon(self): + for identifier, name, item_desc, icon, num in constants.DEPTH_ENUM.keywords['items']: + if identifier == self.depth: + return icon + status: EnumProperty( name="Status", description="SVN Status of the file in the local repository (aka working copy)", @@ -76,22 +85,18 @@ class SVN_file(PropertyGroup): ("SKIP_ONCE", "Skip Once", "File status is predicted by a working-copy svn file operation, like Revert. Next status update should be ignored, and this enum should be set to SKIPPED_ONCE."), ("SKIPPED_ONCE", "Skipped Once", "File status update was skipped. Next status update can be considered accurate, and this flag can be reset to NONE. Until then, operations on this file should remain disabled."), ], - description="Internal flag that notes what process set a predicted status on this file. Should be empty string when the status is not predicted but confirmed. When svn commit/update predicts a status, that status should not be overwritten until the process is finished. With instantaneous processes, a single status update should be ignored since it may be outdated", + description="Internal flag that notes what process set a predicted status on this file. Used to prevent conflicting operations while one operation is in progress", options=set() ) include_in_commit: BoolProperty( name="Commit", - description="Whether this file should be included in the commit or not", + description="Whether this file should be included in the commit or not. Used by the commit operator UI", default=False, options=set() ) @property - def is_outdated(self): - return self.repos_status == 'modified' and self.status == 'normal' - - @property - def is_dir(self): + def is_dir(self) -> bool: if self.exists: return Path(self.absolute_path).is_dir() else: @@ -152,7 +157,7 @@ class SVN_file(PropertyGroup): return 'QUESTION' @property - def has_default_status(self): + def has_default_status(self) -> bool: return self.status == 'normal' and self.repos_status == 'none' and self.status_prediction_type == 'NONE' show_in_filelist: BoolProperty( @@ -161,20 +166,35 @@ class SVN_file(PropertyGroup): default=False ) - def get_file_size(self): + ### File size - Update a string representation one time when file_size_KiB is set. ### + ### We want to avoid re-calculating that string on every re-draw for optimization. ### + def get_file_size_ui_str(self): num = self.file_size_KiB for unit in ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB"): if num < 1024: - return f"{num:3.1f} {unit}" + num_digits = len(str(num).split(".")[0]) + file_size = f"{num:0.{max(0, 3-num_digits)}f} {unit}" + if "." not in file_size: + file_size = file_size.replace(" ", " ") + return file_size num /= 1024.0 - return f"{num:.1f} YiB" + + return "Really Big" def update_file_size(self, _context): - self.file_size = self.get_file_size() + self.file_size = self.get_file_size_ui_str() file_size_KiB: FloatProperty(description="One KibiByte (KiB) is 1024 bytes", update=update_file_size) file_size: StringProperty(description="File size for displaying in the UI") + ### File tree ### + def update_is_expanded(self, context): + repo = context.scene.svn.get_repo(context) + repo.external_files_active_index = repo.external_files.find(self.name) + repo.refresh_ui_lists(context) + is_expanded: BoolProperty(name="Show Contents", update=update_is_expanded, description="Whether this directory's contents should be shown in file tree view") + tree_depth: IntProperty(description="Number of indentations in the tree, ie. number of parents. Set automatically when svn_path is set") + has_children: BoolProperty(description="Whether this is a directory with any children. Updated whenever a new file entry is added") class SVN_log(PropertyGroup): """Property Group that can represent an SVN log entry.""" @@ -260,6 +280,7 @@ class SVN_repository(PropertyGroup): url: StringProperty( name="URL", description="URL of the remote repository", + update=update_repo_info_file ) def update_directory(self, context): @@ -287,6 +308,9 @@ class SVN_repository(PropertyGroup): dir_path = Path(self.directory) # TODO: This property is checked pretty often, so we run `svn info` pretty often. Might not be a big deal, but maybe it's a bit overkill? root_dir, base_url = get_svn_info(self.directory) + if not hasattr(self, 'directory'): + # This can happen when running Reload Scripts, resulting in a console error. + return False return ( dir_path.exists() and dir_path.is_dir() and @@ -425,6 +449,15 @@ class SVN_repository(PropertyGroup): ### SVN File List. ### external_files: CollectionProperty(type=SVN_file) + def file_add(self, svn_path: Path or str) -> SVN_file: + file_entry = self.external_files.add() + file_entry.svn_path = svn_path + file_entry.absolute_path = str(self.svn_to_absolute_path(svn_path)) + parent = self.get_parent_file(file_entry) + if parent: + parent.has_children = True + return file_entry + def remove_file_entry(self, file_entry: SVN_file): """Remove a file entry from the file list, based on its filepath.""" for i, f in enumerate(self.external_files): @@ -450,7 +483,7 @@ class SVN_repository(PropertyGroup): return svn_dir / svn_path def get_file_by_absolute_path(self, abs_path: str or Path) -> Optional[SVN_file]: - rel_path = str(self.absolute_to_svn_path(abs_path)) + rel_path = str(self.absolute_to_svn_path(abs_path).as_posix()) if rel_path: return self.external_files.get(rel_path) @@ -503,6 +536,30 @@ class SVN_repository(PropertyGroup): for log_entry in self.log] ) + def get_root_subdirs(self): + for f in self.external_files: + if not self.get_parent_file(f): + yield f + + def find_dir_children(self, dir: SVN_file): + for f in self.external_files: + if self.get_parent_file(f) == dir: + yield f + + def calculate_folder_sizes(self): + for root_subdir in self.get_root_subdirs(): + self.calculate_folder_size_recursive(root_subdir) + + def calculate_folder_size_recursive(self, dir: SVN_file): + size = 0 + for child in self.find_dir_children(dir): + if child.is_dir: + self.calculate_folder_size_recursive(child) + size += child.file_size_KiB + + dir.file_size_KiB = size + return size + prev_external_files_active_index: IntProperty( name="Previous Active Index", description="Internal value to avoid triggering the update callback unnecessarily", @@ -515,8 +572,11 @@ class SVN_repository(PropertyGroup): options=set(), ) + def get_parent_file(self, file: SVN_file) -> Optional[SVN_file]: + return self.external_files.get(str(Path(file.svn_path).parent.as_posix())) + @property - def active_file(self) -> SVN_file: + def active_file(self) -> Optional[SVN_file]: if len(self.external_files) == 0: return return self.external_files[self.external_files_active_index] @@ -550,8 +610,9 @@ class SVN_repository(PropertyGroup): return svn_file @property - def current_blend_file(self) -> SVN_file: - return self.get_file_by_absolute_path(bpy.data.filepath) + def current_blend_file(self) -> Optional[SVN_file]: + if bpy.data.filepath: + return self.get_file_by_absolute_path(bpy.data.filepath) ### File List UIList filter properties ### def refresh_ui_lists(self, context): @@ -559,7 +620,11 @@ class SVN_repository(PropertyGroup): Also triggers a refresh of the SVN UIList, through the update callback of external_files_active_index.""" + if len(self.external_files) == 0: + return + UI_LIST = bpy.types.UI_UL_list + if self.file_search_filter: filter_list = UI_LIST.filter_items_by_name( self.file_search_filter, @@ -570,16 +635,29 @@ class SVN_repository(PropertyGroup): ) filter_list = [bool(val) for val in filter_list] self.external_files.foreach_set('show_in_filelist', filter_list) + if self.display_mode == 'TREE': + for file in self.external_files: + if file.show_in_filelist: + parent = self.get_parent_file(file) + while parent: + parent.show_in_filelist = True + parent = self.get_parent_file(parent) else: + self.external_files.foreach_set('show_in_filelist', [True] * len(self.external_files)) + + if self.display_mode == 'TREE': + for file in self.external_files: + parent = self.get_parent_file(file) + while parent: + if not parent.is_expanded: + file.show_in_filelist = False + break + parent = self.get_parent_file(parent) + elif not self.file_search_filter: for file in self.external_files: - if file == self.current_blend_file: - file.show_in_filelist = True - continue - file.show_in_filelist = not file.has_default_status - - if len(self.external_files) == 0: - return + if self.current_blend_file: + self.current_blend_file.show_in_filelist = True # Make sure the active file isn't now being filtered out. # If it is, change the active file to the first visible one. @@ -596,6 +674,31 @@ class SVN_repository(PropertyGroup): update=refresh_ui_lists ) + show_file_size: BoolProperty( + name="Show Size On Disk", + description="Show size of each file and aggregate size of folders", + default=False + ) + show_file_paths: BoolProperty( + name="Show File Paths", + description="Show file paths relative to the SVN root, instead of just the file name" + ) + + def update_tree_display(self, context): + if self.display_mode == 'TREE': + self.show_file_paths = False + self.refresh_ui_lists(context) + + display_mode: EnumProperty( + name="File Display Mode", + description="Whether the full file tree sould be drawn instead of just modified files as a flat list", + items=[ + ('FLAT', "Changes", "Display only modified files as a flat list", 'PRESET', 0), + ('TREE', "File Tree", "Display the full tree of the entire repository", 'OUTLINER', 1), + ], + update=update_tree_display + ) + registry = [ SVN_file, diff --git a/scripts-blender/addons/blender_svn/threaded/execute_subprocess.py b/scripts-blender/addons/blender_svn/threaded/execute_subprocess.py index 7ee07ba8..3a8b9596 100644 --- a/scripts-blender/addons/blender_svn/threaded/execute_subprocess.py +++ b/scripts-blender/addons/blender_svn/threaded/execute_subprocess.py @@ -38,6 +38,9 @@ def execute_svn_command( So any file paths that are part of the command should be relative to the SVN root. """ + if not hasattr(context.scene, 'svn'): + # This can happen during Reload Scripts, throwing a console error. + return repo = context.scene.svn.get_repo(context) if "svn" not in command: command.insert(0, "svn") diff --git a/scripts-blender/addons/blender_svn/threaded/svn_log.py b/scripts-blender/addons/blender_svn/threaded/svn_log.py index 6f0e8847..670ca684 100644 --- a/scripts-blender/addons/blender_svn/threaded/svn_log.py +++ b/scripts-blender/addons/blender_svn/threaded/svn_log.py @@ -74,7 +74,7 @@ def reload_svn_log(self, context): file_path = Path(file_path) log_file_entry = log_entry.changed_files.add() - log_file_entry.svn_path = str(file_path.as_posix()) + log_file_entry.svn_path = file_path log_file_entry.revision = r_number log_file_entry.status = constants.SVN_STATUS_CHAR_TO_NAME[status_char] diff --git a/scripts-blender/addons/blender_svn/threaded/svn_status.py b/scripts-blender/addons/blender_svn/threaded/svn_status.py index 29c04cc4..49e14629 100644 --- a/scripts-blender/addons/blender_svn/threaded/svn_status.py +++ b/scripts-blender/addons/blender_svn/threaded/svn_status.py @@ -137,7 +137,6 @@ class BGP_SVN_Status(BackgroundProcess): def __init__(self): self.timestamp_last_update = 0 - self.list_command_output = "" super().__init__() def acquire_output(self, context, prefs): @@ -146,25 +145,15 @@ class BGP_SVN_Status(BackgroundProcess): ["svn", "status", "--show-updates", "--verbose", "--xml"], use_cred=True, ) - # The list command includes file size info and also files of directories - # which have their Depth set to Empty, which is used for a partial check-out, - # which we also use for updating files and folders one-by-one instead of - # all-at-once, so we can provide more live feedback in the UI. - # NOTE: This one-by-one updating functionality conflicts with a potential - # future support for partial check-outs, so that would require storing user-intended - # partially checked out folders separately somewhere. - self.list_command_output = execute_svn_command( - context, - ["svn", "list", "--recursive", "--xml"], - use_cred=True, - ) def process_output(self, context, prefs): repo = context.scene.svn.get_repo(context) update_file_list_svn_status(context, svn_status_xml_to_dict(self.output)) - update_file_list_svn_list(context, self.list_command_output) repo.refresh_ui_lists(context) self.timestamp_last_update = time.time() + if prefs.do_auto_updates: + if any([f.repos_status != 'none' for f in repo.external_files]): + Processes.start('Update') def get_ui_message(self, context): time_since_last_update = time.time() - self.timestamp_last_update @@ -175,6 +164,34 @@ class BGP_SVN_Status(BackgroundProcess): return f"Updating repo status..." +class BGP_SVN_List(BackgroundProcess): + """The `svn list` command includes file size info and files excluded via + partial checkout functionality (`--set-depth empty`). + However, it is very slow, so this should be used sparingly. + """ + + name = "List" + needs_authentication = True + timeout = 30 + repeat_delay = 0 + debug = False + + def acquire_output(self, context, prefs): + self.output = execute_svn_command( + context, + ["svn", "list", "--recursive", "--xml", "-r", "HEAD"], + use_cred=True, + ) + + def process_output(self, context, prefs): + repo = context.scene.svn.get_repo(context) + update_file_list_svn_list(context, self.output) + repo.refresh_ui_lists(context) + + def get_ui_message(self, context): + return f"Updating file list..." + + class BGP_SVN_Authenticate(BGP_SVN_Status): name = "Authenticate" needs_authentication = False @@ -225,9 +242,13 @@ def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str svn_paths = [] new_files_on_repo = set() - for filepath_str, status_info in file_statuses.items(): + file_statuses = [(key, value) for key, value in file_statuses.items()] + file_statuses.sort(key=lambda x: x[0]) + for filepath_str, status_info in file_statuses: + if filepath_str == ".": + continue svn_path = Path(filepath_str) - svn_path_str = str(filepath_str) + svn_path_str = str(svn_path.as_posix()) suffix = svn_path.suffix if ( (suffix.startswith(".r") and suffix[2:].isdecimal()) @@ -248,8 +269,7 @@ def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str entry_existed = True if not file_entry: entry_existed = False - file_entry = repo.external_files.add() - file_entry.svn_path = svn_path_str + file_entry = repo.file_add(svn_path_str) if not file_entry.exists: new_files_on_repo.add((file_entry.svn_path, repos_status)) @@ -263,7 +283,6 @@ def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str file_entry.status = wc_status file_entry.repos_status = repos_status file_entry.status_prediction_type = 'NONE' - file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path)) if new_files_on_repo: # File entry status has changed between local and repo. @@ -278,6 +297,9 @@ def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str ) Processes.start('Log') + if any([not f.is_dir and not f.file_size_KiB for f in repo.external_files]): + Processes.start('List') + # Remove file entries who no longer seem to have an SVN status. # This can happen if an unversioned file was removed from the filesystem, # Or sub-folders whose parent was Un-Added to the SVN. @@ -324,23 +346,27 @@ def svn_status_xml_to_dict(svn_status_str: str) -> Dict[str, Tuple[str, str, int def update_file_list_svn_list(context, svn_list_str: str) -> Dict: - repo = context.scene.svn.get_repo(context) - try: - svn_list_xml = xmltodict.parse(svn_list_str) - except: - # This seems to fail with an "ExpatError" on Windows...? + if not svn_list_str: + # This shouldn't happen. return + repo = context.scene.svn.get_repo(context) + svn_list_xml = xmltodict.parse(svn_list_str) file_infos = svn_list_xml['lists']['list']['entry'] for file_info in file_infos: - svn_path = file_info['name'] + svn_path = str(Path(file_info['name']).as_posix()) + if svn_path == ".": + continue kind = file_info['@kind'] file_entry = repo.external_files.get(svn_path) if not file_entry: - file_entry = repo.external_files.add() + # `svn list` will only catch new file entries which are missing due to + # a sparse checkout. + # TODO: The ability to include files that are excluded by a sparse checkout may be useful still. + continue + file_entry = repo.file_add(svn_path) file_entry.svn_path = svn_path - file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path)) if not file_entry.exists: file_entry.status = 'none' file_entry.repos_status = 'added' @@ -348,6 +374,9 @@ def update_file_list_svn_list(context, svn_list_str: str) -> Dict: if kind == 'file': file_entry.file_size_KiB = float(file_info['size']) / 1024.0 + # Calculate size of folders. + repo.calculate_folder_sizes() + @bpy.app.handlers.persistent def mark_current_file_as_modified(_dummy1=None, _dummy2=None): diff --git a/scripts-blender/addons/blender_svn/threaded/update.py b/scripts-blender/addons/blender_svn/threaded/update.py index dfb83f26..c3445774 100644 --- a/scripts-blender/addons/blender_svn/threaded/update.py +++ b/scripts-blender/addons/blender_svn/threaded/update.py @@ -64,7 +64,7 @@ class BGP_SVN_Update(BackgroundProcess): print(self.message) self.set_predicted_file_status(file) - command = ["svn", "up", file.svn_path, "--accept", "postpone", "--depth", "empty"] + command = ["svn", "up", file.svn_path, "--accept", "postpone"]#, "--depth", "empty"] if self.revision > 0: command.insert(2, f"-r{self.revision}") self.output = execute_svn_command(context, command, use_cred=True) diff --git a/scripts-blender/addons/blender_svn/ui/ui_context_menus.py b/scripts-blender/addons/blender_svn/ui/ui_context_menus.py index ed9748b8..0a41c621 100644 --- a/scripts-blender/addons/blender_svn/ui/ui_context_menus.py +++ b/scripts-blender/addons/blender_svn/ui/ui_context_menus.py @@ -24,23 +24,29 @@ def svn_file_list_context_menu(self: UIList, context: Context) -> None: repo = context.scene.svn.get_repo(context) active_file = repo.active_file file_abs_path = repo.get_file_abspath(active_file) + + layout.operator("wm.path_open", + text=f"Open Containing Folder", icon='FILE_FOLDER').filepath = Path(file_abs_path).parent.as_posix() + if active_file.name.endswith("blend"): op = layout.operator("wm.open_mainfile", - text=f"Open {active_file.name}") + text=f"Open {active_file.name}", icon='FILE_BLEND') op.filepath = str(file_abs_path) op.display_file_selector = False op.load_ui = True op = layout.operator("wm.open_mainfile", - text=f"Open {active_file.name} (Keep UI)") + text=f"Open {active_file.name} (Keep UI)", icon='FILE_BLEND') op.filepath = str(file_abs_path) op.display_file_selector = False op.load_ui = False else: layout.operator("wm.path_open", - text=f"Open {active_file.name}").filepath = str(file_abs_path) - layout.operator("wm.path_open", - text=f"Open Containing Folder").filepath = Path(file_abs_path).parent.as_posix() + text=f"Open {active_file.name}", icon=active_file.file_icon).filepath = str(file_abs_path) + + if active_file.is_dir: + layout.operator_menu_enum('svn.set_directory_depth', 'depth', text="Set SVN Depth", icon='OUTLINER') + layout.separator() diff --git a/scripts-blender/addons/blender_svn/ui/ui_file_list.py b/scripts-blender/addons/blender_svn/ui/ui_file_list.py index 2a2d5fc4..568d680a 100644 --- a/scripts-blender/addons/blender_svn/ui/ui_file_list.py +++ b/scripts-blender/addons/blender_svn/ui/ui_file_list.py @@ -5,25 +5,36 @@ import time import bpy from bpy.types import UIList -from bpy.props import BoolProperty from .. import constants from ..util import get_addon_prefs, dots from ..threaded.background_process import Processes +def file_list_ui_split(layout, context): + repo = context.scene.svn.get_repo(context) + main_split = layout.split(factor=0.9) + left_side = main_split.row() + + filepath_ui = left_side.row() + if repo.show_file_size: + filesize_ui = layout.row() + filesize_ui.alignment='RIGHT' + else: + filesize_ui = None + status_ui = layout.row(align=True) + status_ui.alignment='RIGHT' + ops_ui = layout.row(align=True) + + return filepath_ui, filesize_ui, status_ui, ops_ui + class SVN_UL_file_list(UIList): # Value that indicates that this item has passed the filter process successfully. See rna_ui.c. UILST_FLT_ITEM = 1 << 30 - show_file_paths: BoolProperty( - name="Show File Paths", - description="Show file paths relative to the SVN root, instead of just the file name" - ) - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - # As long as there are any items, always draw the filters. - self.use_filter_show = True + # As long as there are any items, and a search term is typed in, always draw the filter. + repo = context.scene.svn.get_repo(context) if self.layout_type != 'DEFAULT': raise NotImplemented @@ -31,23 +42,49 @@ class SVN_UL_file_list(UIList): file_entry = item prefs = get_addon_prefs(context) - main_row = layout.row() - split = main_row.split(factor=0.6) - filepath_ui = split.row() - split = split.split(factor=0.4) - status_ui = split.row(align=True) - - ops_ui = split.row(align=True) - ops_ui.alignment = 'RIGHT' + filepath_ui, filesize_ui, status_ui, ops_ui = file_list_ui_split(layout, context) ops_ui.enabled = file_entry.status_prediction_type == 'NONE' and not prefs.is_busy - if self.show_file_paths: - filepath_ui.prop(file_entry, 'name', text="", - emboss=False, icon=file_entry.file_icon) + if repo.display_mode == 'TREE': + split = filepath_ui.split(factor=0.05 * file_entry.tree_depth + 0.00001) + split.label() + row = split.row(align=True) + filepath_ui = row.row(align=True) + if file_entry.has_children: + icon = 'DOWNARROW_HLT' if file_entry.is_expanded else 'RIGHTARROW' + filepath_ui.prop(file_entry, 'is_expanded', text="", icon=icon, emboss=False) + else: + filepath_ui.label(text="", icon='BLANK1') + + if repo.show_file_paths: + filepath_ui.label(text=file_entry.name, icon=file_entry.file_icon) else: filepath_ui.label(text=file_entry.file_name, icon=file_entry.file_icon) + if repo.show_file_size: + icon_map = { + 'KiB' : 'DECORATE', + 'MiB' : 'RADIOBUT_ON', + 'GiB' : 'SHADING_SOLID', + 'TiB' : 'SHADING_SOLID', + 'PiB' : 'SHADING_SOLID', + 'EiB' : 'SHADING_SOLID', + } + icon = 'BLANK1 ' + if file_entry.file_size_KiB == 0: + icon = 'BLANK1' + text=" " + else: + icon = icon_map[file_entry.file_size[-3:]] + text=file_entry.file_size + width = get_sidebar_width(context) + if width < 800: + text = text.replace("iB", "") + filesize_ui.label(text=text) + else: + filesize_ui.label(text=text, icon=icon) + statuses = [file_entry.status] # SVN operations ops = [] @@ -61,7 +98,7 @@ class SVN_UL_file_list(UIList): ops.append(ops_ui.operator( 'svn.unadd_file', text="", icon='REMOVE')) elif file_entry.status == 'unversioned': - ops.append(ops_ui.operator('svn.add_file', text="", icon='ADD')) + ops.append(ops_ui.operator('svn.file_add', text="", icon='ADD')) ops.append(ops_ui.operator( 'svn.trash_file', text="", icon='TRASH')) @@ -74,8 +111,9 @@ class SVN_UL_file_list(UIList): # I think it's better to let the user know in advance. statuses.append('conflicted') # Updating the file will create an actual conflict. - ops.append(ops_ui.operator( - 'svn.update_single', text="", icon='IMPORT')) + if not prefs.do_auto_updates: + ops.append(ops_ui.operator( + 'svn.update_single', text="", icon='IMPORT')) elif file_entry.status == 'conflicted': ops.append(ops_ui.operator('svn.resolve_conflict', @@ -88,23 +126,36 @@ class SVN_UL_file_list(UIList): # From user POV it makes a bit more sense to call a file that doesn't # exist yet "added" instead of "outdated". statuses.append('added') - ops.append(ops_ui.operator( - 'svn.update_single', text="", icon='IMPORT')) + if not prefs.do_auto_updates: + ops.append(ops_ui.operator( + 'svn.update_single', text="", icon='IMPORT')) elif file_entry.status == 'normal' and file_entry.repos_status == 'modified': # From user POV, this file is outdated, not 'normal'. statuses = ['none'] - ops.append(ops_ui.operator( - 'svn.update_single', text="", icon='IMPORT')) + if not prefs.do_auto_updates: + ops.append(ops_ui.operator( + 'svn.update_single', text="", icon='IMPORT')) elif file_entry.status in ['normal', 'external', 'ignored']: pass else: print("Unknown file status: ", file_entry.svn_path, file_entry.status, file_entry.repos_status) + if file_entry.is_dir: + op = ops_ui.operator('svn.set_directory_depth', text="", icon=file_entry.depth_icon) + op.popup=True + ops.append(op) + + for i in range(max(0, 2-len(ops))): + ops_ui.label(text="", icon='BLANK1') + for op in ops: if hasattr(op, 'file_rel_path'): op.file_rel_path = file_entry.svn_path + for i in range(max(0, 2-len(statuses))): + status_ui.label(text="", icon='BLANK1') + # Populate the status icons. for status in statuses: icon = constants.SVN_STATUS_DATA[status][0] @@ -136,30 +187,27 @@ class SVN_UL_file_list(UIList): return flt_flags, flt_neworder def filter_items(self, context, data, propname): + # As long as a search term is typed in, draw the filter. + repo = context.scene.svn.get_repo(context) + if repo.file_search_filter: + self.use_filter_show = True return type(self).cls_filter_items(context, data, propname) def draw_filter(self, context, layout): """Custom filtering UI. Toggles are stored in addon preferences, see cls_filter_items(). """ - main_row = layout.row() - row = main_row.row(align=True) - - row.prop(self, 'show_file_paths', text="", - toggle=True, icon="FILE_FOLDER") - repo = context.scene.svn.get_repo(context) - if repo: - row.prop(repo, 'file_search_filter', text="") + if not repo: + return + layout.prop(repo, 'file_search_filter', text="") def draw_process_info(context, layout): prefs = get_addon_prefs(context) - process_message = "" - any_error = False col = layout.column() for process in Processes.processes.values(): - if process.name not in {'Commit', 'Update', 'Log', 'Status', 'Authenticate'}: + if process.name in {'Redraw Viewport', 'Activate File'}: continue if process.error: @@ -168,22 +216,46 @@ def draw_process_info(context, layout): warning = row.operator( 'svn.clear_error', text=f"SVN {process.name}: Error Occurred. Hover to view", icon='ERROR') warning.process_id = process.name - any_error = True - break + continue if process.is_running: message = process.get_ui_message(context) if message: message = message.replace("...", dots()) - process_message = f"SVN: {message}" + col.label(text=message) - if not any_error and process_message: - col.label(text=process_message) if prefs.debug_mode: col.label(text="Processes: " + ", ".join([p.name for p in Processes.running_processes])) +class SVN_PT_filelist_options(bpy.types.Panel): + bl_space_type = 'VIEW_3D' + bl_region_type = 'WINDOW' + bl_label = "Options" + bl_options = {'INSTANCED'} + + def draw(self, context): + layout = self.layout + layout.use_property_split=True + layout.use_property_decorate=False + layout.ui_units_x = 17.0 + + repo = context.scene.svn.get_repo(context) + + layout.prop(repo, 'display_mode', text="Display Mode", expand=True) + + file_paths = layout.row() + file_paths.enabled = repo.display_mode == 'FLAT' + file_paths.prop(repo, 'show_file_paths') + + layout.row().prop(repo, 'show_file_size') + +def get_sidebar_width(context) -> float: + for region in context.area.regions: + if region.type == 'UI': + return region.width + def draw_file_list(context, layout): prefs = get_addon_prefs(context) repo = prefs.active_repo @@ -196,20 +268,25 @@ def draw_file_list(context, layout): row.label(text="Repository is not authenticated.", icon='ERROR') return - main_col = layout.column() - main_row = main_col.row() - split = main_row.split(factor=0.6) - filepath_row = split.row() - filepath_row.label(text=" Filepath") + width = get_sidebar_width(context) + layout.label(text=str(width)) - status_row = split.row() - status_row.label(text=" Status") + top_row = layout.row() + box = top_row.box().row() + filepath_ui, filesize_ui, status_ui, ops_ui = file_list_ui_split(box, context) + filepath_ui.alignment='CENTER' + filepath_ui.label(text="File") - ops_row = main_row.row() - ops_row.alignment = 'RIGHT' - ops_row.label(text="Operations") + if repo.show_file_size: + filesize_ui.label(text="Size") + status_ui.label(text="Status") - row = main_col.row() + ops_ui.alignment = 'RIGHT' + ops_ui.label(text="Actions") + + top_row.column().prop(prefs, 'do_auto_updates', text="", icon='TEMP') + + row = layout.row() row.template_list( "SVN_UL_file_list", "svn_file_list", @@ -221,9 +298,22 @@ def draw_file_list(context, layout): col = row.column() + col.popover( + panel="SVN_PT_filelist_options", + text="", + icon='PREFERENCES', + ) + col.separator() + col.operator("svn.commit", icon='EXPORT', text="") - col.operator("svn.update_all", icon='IMPORT', text="").revision = 0 + if not prefs.do_auto_updates: + col.operator("svn.update_all", icon='IMPORT', text="").revision = 0 + + if prefs.is_busy: + col.operator('svn.cancel', text="", icon="X") + else: + col.label(text="", icon='BLANK1') col.separator() col.operator("svn.cleanup", icon='BRUSH_DATA', text="") @@ -231,4 +321,5 @@ def draw_file_list(context, layout): registry = [ SVN_UL_file_list, + SVN_PT_filelist_options, ] diff --git a/scripts-blender/addons/blender_svn/ui/ui_sidebar.py b/scripts-blender/addons/blender_svn/ui/ui_sidebar.py index a0e47123..2ddd3bd5 100644 --- a/scripts-blender/addons/blender_svn/ui/ui_sidebar.py +++ b/scripts-blender/addons/blender_svn/ui/ui_sidebar.py @@ -53,8 +53,8 @@ class VIEW3D_PT_svn_files(Panel): layout.use_property_split = True layout.use_property_decorate = False - draw_process_info(context, layout) draw_file_list(context, layout) + draw_process_info(context, layout) registry = [