Blender SVN: New Features #273

Open
Demeter Dzadik wants to merge 13 commits from New-SVN-features into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
12 changed files with 463 additions and 121 deletions

View File

@ -1,6 +1,7 @@
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# (c) 2022, Blender Foundation - Demeter Dzadik # (c) 2022, Blender Foundation - Demeter Dzadik
import bpy
from collections import OrderedDict from collections import OrderedDict
SVN_STATUS_DATA = OrderedDict( SVN_STATUS_DATA = OrderedDict(
@ -94,7 +95,6 @@ SVN_STATUS_DATA = OrderedDict(
] ]
) )
# Based on PySVN/svn/constants.py/STATUS_TYPE_LOOKUP. # Based on PySVN/svn/constants.py/STATUS_TYPE_LOOKUP.
ENUM_SVN_STATUS = [ ENUM_SVN_STATUS = [
(status, status.title(), (status, status.title(),
@ -119,3 +119,15 @@ SVN_STATUS_CHAR_TO_NAME = {
SVN_STATUS_NAME_TO_CHAR = {value: key for key, SVN_STATUS_NAME_TO_CHAR = {value: key for key,
value in SVN_STATUS_CHAR_TO_NAME.items()} 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'
)

View File

@ -14,6 +14,7 @@ from send2trash import send2trash
from ..threaded.execute_subprocess import execute_svn_command from ..threaded.execute_subprocess import execute_svn_command
from ..threaded.background_process import Processes from ..threaded.background_process import Processes
from ..util import get_addon_prefs, redraw_viewport from ..util import get_addon_prefs, redraw_viewport
from ..constants import DEPTH_ENUM
class SVN_Operator: 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): class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
bl_idname = "svn.download_file_revision" bl_idname = "svn.download_file_revision"
bl_label = "Download 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'} bl_options = {'INTERNAL'}
missing_file_allowed = True missing_file_allowed = True
@ -211,6 +212,8 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
return {'CANCELLED'} return {'CANCELLED'}
self.svn_download_file_revision(context, self.file_rel_path, self.revision) self.svn_download_file_revision(context, self.file_rel_path, self.revision)
prefs = get_addon_prefs(context)
prefs.do_auto_updates = False
self.report( self.report(
{'INFO'}, f"Checked out revision {self.revision} of {self.file_rel_path}" {'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' 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): class SVN_OT_cleanup(SVN_Operator, Operator):
bl_idname = "svn.cleanup" bl_idname = "svn.cleanup"
bl_label = "SVN Cleanup" bl_label = "SVN Cleanup"
@ -469,8 +519,12 @@ class SVN_OT_cleanup(SVN_Operator, Operator):
def execute(self, context: Context) -> Set[str]: def execute(self, context: Context) -> Set[str]:
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
prefs = get_addon_prefs(context)
repo.external_files.clear() 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"]) self.execute_svn_command(context, ["svn", "cleanup"])
repo.reload_svn_log(context) repo.reload_svn_log(context)
@ -488,6 +542,17 @@ class SVN_OT_cleanup(SVN_Operator, Operator):
return {"FINISHED"} 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 = [ registry = [
SVN_OT_update_single, SVN_OT_update_single,
SVN_OT_revert_and_update, SVN_OT_revert_and_update,
@ -499,5 +564,7 @@ registry = [
SVN_OT_trash_file, SVN_OT_trash_file,
SVN_OT_remove_file, SVN_OT_remove_file,
SVN_OT_resolve_conflict, SVN_OT_resolve_conflict,
SVN_OT_set_directory_depth,
SVN_OT_cleanup, SVN_OT_cleanup,
SVN_OT_cancel_running_operation,
] ]

View File

@ -15,7 +15,7 @@ from ..util import get_addon_prefs
class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator): class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
bl_idname = "svn.update_all" bl_idname = "svn.update_all"
bl_label = "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'} bl_options = {'INTERNAL'}
revision: IntProperty( revision: IntProperty(
@ -24,6 +24,16 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
default=0 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 @classmethod
def poll(cls, context): def poll(cls, context):
if get_addon_prefs(context).is_busy: 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]: def execute(self, context: Context) -> Set[str]:
Processes.stop('Status') Processes.stop('Status')
prefs = get_addon_prefs(context)
prefs.do_auto_updates = False
if self.reload_file: if self.reload_file:
current_file = context.scene.svn.get_repo(context).current_blend_file current_file = context.scene.svn.get_repo(context).current_blend_file
command = ["svn", "up", current_file.svn_path, "--accept", "postpone"] command = ["svn", "up", current_file.svn_path, "--accept", "postpone"]

View File

@ -26,6 +26,12 @@ class SVN_addon_preferences(AddonPreferences):
default=False 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) repositories: CollectionProperty(type=SVN_repository)
def init_repo_list(self): def init_repo_list(self):
@ -110,6 +116,9 @@ class SVN_addon_preferences(AddonPreferences):
def is_busy(self): def is_busy(self):
return Processes.is_running('Commit', 'Update') 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( loading: BoolProperty(
name="Loading", name="Loading",
description="Disable the credential update callbacks while loading repo data to avoid infinite loops", 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', saved_props = {'url', 'directory', 'name',
'username', 'password', 'display_name'} 'username', 'password', 'display_name'}
repo_data = {} repo_data = {}
for repo in self['repositories']: for repo_dict, repo in zip(self['repositories'], self.repositories):
directory = repo.get('directory', '') directory = repo_dict.get('directory', '')
repo_data[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')) / \ filepath = Path(bpy.utils.user_resource('CONFIG')) / \
Path("blender_svn.txt") Path("blender_svn.txt")
@ -149,6 +160,13 @@ class SVN_addon_preferences(AddonPreferences):
repo.directory = directory repo.directory = directory
for key, value in repo_data.items(): 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: finally:
self.loading = False self.loading = False
@ -166,7 +184,6 @@ class SVN_addon_preferences(AddonPreferences):
else: else:
draw_repo_list(self, context) draw_repo_list(self, context)
def draw_prefs_no_svn(self, context): def draw_prefs_no_svn(self, context):
terminal, url = "terminal", "https://subversion.apache.org/packages.html" terminal, url = "terminal", "https://subversion.apache.org/packages.html"
system = platform.system() system = platform.system()

View File

@ -36,7 +36,9 @@ class SVN_file(PropertyGroup):
@svn_path.setter @svn_path.setter
def svn_path(self, value): 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 @property
def file_name(self): def file_name(self):
@ -48,6 +50,13 @@ class SVN_file(PropertyGroup):
options=set() 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( status: EnumProperty(
name="Status", name="Status",
description="SVN Status of the file in the local repository (aka working copy)", 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."), ("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."), ("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() options=set()
) )
include_in_commit: BoolProperty( include_in_commit: BoolProperty(
name="Commit", 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, default=False,
options=set() options=set()
) )
@property @property
def is_outdated(self): def is_dir(self) -> bool:
return self.repos_status == 'modified' and self.status == 'normal'
@property
def is_dir(self):
if self.exists: if self.exists:
return Path(self.absolute_path).is_dir() return Path(self.absolute_path).is_dir()
else: else:
@ -152,7 +157,7 @@ class SVN_file(PropertyGroup):
return 'QUESTION' return 'QUESTION'
@property @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' return self.status == 'normal' and self.repos_status == 'none' and self.status_prediction_type == 'NONE'
show_in_filelist: BoolProperty( show_in_filelist: BoolProperty(
@ -161,20 +166,35 @@ class SVN_file(PropertyGroup):
default=False 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 num = self.file_size_KiB
for unit in ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB"): for unit in ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB"):
if num < 1024: 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 num /= 1024.0
return f"{num:.1f} YiB"
return "Really Big"
def update_file_size(self, _context): 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_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_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): class SVN_log(PropertyGroup):
"""Property Group that can represent an SVN log entry.""" """Property Group that can represent an SVN log entry."""
@ -260,6 +280,7 @@ class SVN_repository(PropertyGroup):
url: StringProperty( url: StringProperty(
name="URL", name="URL",
description="URL of the remote repository", description="URL of the remote repository",
update=update_repo_info_file
) )
def update_directory(self, context): def update_directory(self, context):
@ -287,6 +308,9 @@ class SVN_repository(PropertyGroup):
dir_path = Path(self.directory) 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? # 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) 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 ( return (
dir_path.exists() and dir_path.exists() and
dir_path.is_dir() and dir_path.is_dir() and
@ -425,6 +449,15 @@ class SVN_repository(PropertyGroup):
### SVN File List. ### ### SVN File List. ###
external_files: CollectionProperty(type=SVN_file) 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): def remove_file_entry(self, file_entry: SVN_file):
"""Remove a file entry from the file list, based on its filepath.""" """Remove a file entry from the file list, based on its filepath."""
for i, f in enumerate(self.external_files): for i, f in enumerate(self.external_files):
@ -450,7 +483,7 @@ class SVN_repository(PropertyGroup):
return svn_dir / svn_path return svn_dir / svn_path
def get_file_by_absolute_path(self, abs_path: str or Path) -> Optional[SVN_file]: 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: if rel_path:
return self.external_files.get(rel_path) return self.external_files.get(rel_path)
@ -503,6 +536,30 @@ class SVN_repository(PropertyGroup):
for log_entry in self.log] 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( prev_external_files_active_index: IntProperty(
name="Previous Active Index", name="Previous Active Index",
description="Internal value to avoid triggering the update callback unnecessarily", description="Internal value to avoid triggering the update callback unnecessarily",
@ -515,8 +572,11 @@ class SVN_repository(PropertyGroup):
options=set(), 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 @property
def active_file(self) -> SVN_file: def active_file(self) -> Optional[SVN_file]:
if len(self.external_files) == 0: if len(self.external_files) == 0:
return return
return self.external_files[self.external_files_active_index] return self.external_files[self.external_files_active_index]
@ -550,7 +610,8 @@ class SVN_repository(PropertyGroup):
return svn_file return svn_file
@property @property
def current_blend_file(self) -> SVN_file: def current_blend_file(self) -> Optional[SVN_file]:
if bpy.data.filepath:
return self.get_file_by_absolute_path(bpy.data.filepath) return self.get_file_by_absolute_path(bpy.data.filepath)
### File List UIList filter properties ### ### File List UIList filter properties ###
@ -559,7 +620,11 @@ class SVN_repository(PropertyGroup):
Also triggers a refresh of the SVN UIList, through the update callback of Also triggers a refresh of the SVN UIList, through the update callback of
external_files_active_index.""" external_files_active_index."""
if len(self.external_files) == 0:
return
UI_LIST = bpy.types.UI_UL_list UI_LIST = bpy.types.UI_UL_list
if self.file_search_filter: if self.file_search_filter:
filter_list = UI_LIST.filter_items_by_name( filter_list = UI_LIST.filter_items_by_name(
self.file_search_filter, self.file_search_filter,
@ -570,16 +635,29 @@ class SVN_repository(PropertyGroup):
) )
filter_list = [bool(val) for val in filter_list] filter_list = [bool(val) for val in filter_list]
self.external_files.foreach_set('show_in_filelist', filter_list) self.external_files.foreach_set('show_in_filelist', filter_list)
else: if self.display_mode == 'TREE':
for file in self.external_files: for file in self.external_files:
if file == self.current_blend_file: if file.show_in_filelist:
file.show_in_filelist = True parent = self.get_parent_file(file)
continue 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:
file.show_in_filelist = not file.has_default_status file.show_in_filelist = not file.has_default_status
if self.current_blend_file:
if len(self.external_files) == 0: self.current_blend_file.show_in_filelist = True
return
# Make sure the active file isn't now being filtered out. # Make sure the active file isn't now being filtered out.
# If it is, change the active file to the first visible one. # If it is, change the active file to the first visible one.
@ -596,6 +674,31 @@ class SVN_repository(PropertyGroup):
update=refresh_ui_lists 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 = [ registry = [
SVN_file, SVN_file,

View File

@ -38,6 +38,9 @@ def execute_svn_command(
So any file paths that are part of the command should be relative to the So any file paths that are part of the command should be relative to the
SVN root. 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) repo = context.scene.svn.get_repo(context)
if "svn" not in command: if "svn" not in command:
command.insert(0, "svn") command.insert(0, "svn")

View File

@ -74,7 +74,7 @@ def reload_svn_log(self, context):
file_path = Path(file_path) file_path = Path(file_path)
log_file_entry = log_entry.changed_files.add() 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.revision = r_number
log_file_entry.status = constants.SVN_STATUS_CHAR_TO_NAME[status_char] log_file_entry.status = constants.SVN_STATUS_CHAR_TO_NAME[status_char]

View File

@ -137,7 +137,6 @@ class BGP_SVN_Status(BackgroundProcess):
def __init__(self): def __init__(self):
self.timestamp_last_update = 0 self.timestamp_last_update = 0
self.list_command_output = ""
super().__init__() super().__init__()
def acquire_output(self, context, prefs): def acquire_output(self, context, prefs):
@ -146,25 +145,15 @@ class BGP_SVN_Status(BackgroundProcess):
["svn", "status", "--show-updates", "--verbose", "--xml"], ["svn", "status", "--show-updates", "--verbose", "--xml"],
use_cred=True, 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): def process_output(self, context, prefs):
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
update_file_list_svn_status(context, svn_status_xml_to_dict(self.output)) 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) repo.refresh_ui_lists(context)
self.timestamp_last_update = time.time() 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): def get_ui_message(self, context):
time_since_last_update = time.time() - self.timestamp_last_update time_since_last_update = time.time() - self.timestamp_last_update
@ -175,6 +164,34 @@ class BGP_SVN_Status(BackgroundProcess):
return f"Updating repo status..." 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): class BGP_SVN_Authenticate(BGP_SVN_Status):
name = "Authenticate" name = "Authenticate"
needs_authentication = False needs_authentication = False
@ -225,9 +242,13 @@ def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str
svn_paths = [] svn_paths = []
new_files_on_repo = set() 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 = Path(filepath_str)
svn_path_str = str(filepath_str) svn_path_str = str(svn_path.as_posix())
suffix = svn_path.suffix suffix = svn_path.suffix
if ( if (
(suffix.startswith(".r") and suffix[2:].isdecimal()) (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 entry_existed = True
if not file_entry: if not file_entry:
entry_existed = False entry_existed = False
file_entry = repo.external_files.add() file_entry = repo.file_add(svn_path_str)
file_entry.svn_path = svn_path_str
if not file_entry.exists: if not file_entry.exists:
new_files_on_repo.add((file_entry.svn_path, repos_status)) 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.status = wc_status
file_entry.repos_status = repos_status file_entry.repos_status = repos_status
file_entry.status_prediction_type = 'NONE' file_entry.status_prediction_type = 'NONE'
file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path))
if new_files_on_repo: if new_files_on_repo:
# File entry status has changed between local and 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') 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. # Remove file entries who no longer seem to have an SVN status.
# This can happen if an unversioned file was removed from the filesystem, # This can happen if an unversioned file was removed from the filesystem,
# Or sub-folders whose parent was Un-Added to the SVN. # 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: def update_file_list_svn_list(context, svn_list_str: str) -> Dict:
repo = context.scene.svn.get_repo(context) if not svn_list_str:
try: # This shouldn't happen.
svn_list_xml = xmltodict.parse(svn_list_str)
except:
# This seems to fail with an "ExpatError" on Windows...?
return return
repo = context.scene.svn.get_repo(context)
svn_list_xml = xmltodict.parse(svn_list_str)
file_infos = svn_list_xml['lists']['list']['entry'] file_infos = svn_list_xml['lists']['list']['entry']
for file_info in file_infos: 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'] kind = file_info['@kind']
file_entry = repo.external_files.get(svn_path) file_entry = repo.external_files.get(svn_path)
if not file_entry: 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.svn_path = svn_path
file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path))
if not file_entry.exists: if not file_entry.exists:
file_entry.status = 'none' file_entry.status = 'none'
file_entry.repos_status = 'added' file_entry.repos_status = 'added'
@ -348,6 +374,9 @@ def update_file_list_svn_list(context, svn_list_str: str) -> Dict:
if kind == 'file': if kind == 'file':
file_entry.file_size_KiB = float(file_info['size']) / 1024.0 file_entry.file_size_KiB = float(file_info['size']) / 1024.0
# Calculate size of folders.
repo.calculate_folder_sizes()
@bpy.app.handlers.persistent @bpy.app.handlers.persistent
def mark_current_file_as_modified(_dummy1=None, _dummy2=None): def mark_current_file_as_modified(_dummy1=None, _dummy2=None):

View File

@ -64,7 +64,7 @@ class BGP_SVN_Update(BackgroundProcess):
print(self.message) print(self.message)
self.set_predicted_file_status(file) 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: if self.revision > 0:
command.insert(2, f"-r{self.revision}") command.insert(2, f"-r{self.revision}")
self.output = execute_svn_command(context, command, use_cred=True) self.output = execute_svn_command(context, command, use_cred=True)

View File

@ -24,23 +24,29 @@ def svn_file_list_context_menu(self: UIList, context: Context) -> None:
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
active_file = repo.active_file active_file = repo.active_file
file_abs_path = repo.get_file_abspath(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"): if active_file.name.endswith("blend"):
op = layout.operator("wm.open_mainfile", 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.filepath = str(file_abs_path)
op.display_file_selector = False op.display_file_selector = False
op.load_ui = True op.load_ui = True
op = layout.operator("wm.open_mainfile", 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.filepath = str(file_abs_path)
op.display_file_selector = False op.display_file_selector = False
op.load_ui = False op.load_ui = False
else: else:
layout.operator("wm.path_open", layout.operator("wm.path_open",
text=f"Open {active_file.name}").filepath = str(file_abs_path) text=f"Open {active_file.name}", icon=active_file.file_icon).filepath = str(file_abs_path)
layout.operator("wm.path_open",
text=f"Open Containing Folder").filepath = Path(file_abs_path).parent.as_posix() if active_file.is_dir:
layout.operator_menu_enum('svn.set_directory_depth', 'depth', text="Set SVN Depth", icon='OUTLINER')
layout.separator() layout.separator()

View File

@ -5,25 +5,36 @@ import time
import bpy import bpy
from bpy.types import UIList from bpy.types import UIList
from bpy.props import BoolProperty
from .. import constants from .. import constants
from ..util import get_addon_prefs, dots from ..util import get_addon_prefs, dots
from ..threaded.background_process import Processes 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): class SVN_UL_file_list(UIList):
# Value that indicates that this item has passed the filter process successfully. See rna_ui.c. # Value that indicates that this item has passed the filter process successfully. See rna_ui.c.
UILST_FLT_ITEM = 1 << 30 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): def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
# As long as there are any items, always draw the filters. # As long as there are any items, and a search term is typed in, always draw the filter.
self.use_filter_show = True repo = context.scene.svn.get_repo(context)
if self.layout_type != 'DEFAULT': if self.layout_type != 'DEFAULT':
raise NotImplemented raise NotImplemented
@ -31,23 +42,49 @@ class SVN_UL_file_list(UIList):
file_entry = item file_entry = item
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
main_row = layout.row() filepath_ui, filesize_ui, status_ui, ops_ui = file_list_ui_split(layout, context)
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'
ops_ui.enabled = file_entry.status_prediction_type == 'NONE' and not prefs.is_busy ops_ui.enabled = file_entry.status_prediction_type == 'NONE' and not prefs.is_busy
if self.show_file_paths: if repo.display_mode == 'TREE':
filepath_ui.prop(file_entry, 'name', text="", split = filepath_ui.split(factor=0.05 * file_entry.tree_depth + 0.00001)
emboss=False, icon=file_entry.file_icon) 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: else:
filepath_ui.label(text=file_entry.file_name, icon=file_entry.file_icon) 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] statuses = [file_entry.status]
# SVN operations # SVN operations
ops = [] ops = []
@ -61,7 +98,7 @@ class SVN_UL_file_list(UIList):
ops.append(ops_ui.operator( ops.append(ops_ui.operator(
'svn.unadd_file', text="", icon='REMOVE')) 'svn.unadd_file', text="", icon='REMOVE'))
elif file_entry.status == 'unversioned': 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( ops.append(ops_ui.operator(
'svn.trash_file', text="", icon='TRASH')) 'svn.trash_file', text="", icon='TRASH'))
@ -74,6 +111,7 @@ class SVN_UL_file_list(UIList):
# I think it's better to let the user know in advance. # I think it's better to let the user know in advance.
statuses.append('conflicted') statuses.append('conflicted')
# Updating the file will create an actual conflict. # Updating the file will create an actual conflict.
if not prefs.do_auto_updates:
ops.append(ops_ui.operator( ops.append(ops_ui.operator(
'svn.update_single', text="", icon='IMPORT')) 'svn.update_single', text="", icon='IMPORT'))
@ -88,11 +126,13 @@ class SVN_UL_file_list(UIList):
# From user POV it makes a bit more sense to call a file that doesn't # From user POV it makes a bit more sense to call a file that doesn't
# exist yet "added" instead of "outdated". # exist yet "added" instead of "outdated".
statuses.append('added') statuses.append('added')
if not prefs.do_auto_updates:
ops.append(ops_ui.operator( ops.append(ops_ui.operator(
'svn.update_single', text="", icon='IMPORT')) 'svn.update_single', text="", icon='IMPORT'))
elif file_entry.status == 'normal' and file_entry.repos_status == 'modified': elif file_entry.status == 'normal' and file_entry.repos_status == 'modified':
# From user POV, this file is outdated, not 'normal'. # From user POV, this file is outdated, not 'normal'.
statuses = ['none'] statuses = ['none']
if not prefs.do_auto_updates:
ops.append(ops_ui.operator( ops.append(ops_ui.operator(
'svn.update_single', text="", icon='IMPORT')) 'svn.update_single', text="", icon='IMPORT'))
elif file_entry.status in ['normal', 'external', 'ignored']: elif file_entry.status in ['normal', 'external', 'ignored']:
@ -101,10 +141,21 @@ class SVN_UL_file_list(UIList):
print("Unknown file status: ", file_entry.svn_path, print("Unknown file status: ", file_entry.svn_path,
file_entry.status, file_entry.repos_status) 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: for op in ops:
if hasattr(op, 'file_rel_path'): if hasattr(op, 'file_rel_path'):
op.file_rel_path = file_entry.svn_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. # Populate the status icons.
for status in statuses: for status in statuses:
icon = constants.SVN_STATUS_DATA[status][0] icon = constants.SVN_STATUS_DATA[status][0]
@ -136,30 +187,27 @@ class SVN_UL_file_list(UIList):
return flt_flags, flt_neworder return flt_flags, flt_neworder
def filter_items(self, context, data, propname): 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) return type(self).cls_filter_items(context, data, propname)
def draw_filter(self, context, layout): def draw_filter(self, context, layout):
"""Custom filtering UI. """Custom filtering UI.
Toggles are stored in addon preferences, see cls_filter_items(). 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) repo = context.scene.svn.get_repo(context)
if repo: if not repo:
row.prop(repo, 'file_search_filter', text="") return
layout.prop(repo, 'file_search_filter', text="")
def draw_process_info(context, layout): def draw_process_info(context, layout):
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
process_message = ""
any_error = False
col = layout.column() col = layout.column()
for process in Processes.processes.values(): 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 continue
if process.error: if process.error:
@ -168,22 +216,46 @@ def draw_process_info(context, layout):
warning = row.operator( warning = row.operator(
'svn.clear_error', text=f"SVN {process.name}: Error Occurred. Hover to view", icon='ERROR') 'svn.clear_error', text=f"SVN {process.name}: Error Occurred. Hover to view", icon='ERROR')
warning.process_id = process.name warning.process_id = process.name
any_error = True continue
break
if process.is_running: if process.is_running:
message = process.get_ui_message(context) message = process.get_ui_message(context)
if message: if message:
message = message.replace("...", dots()) 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: if prefs.debug_mode:
col.label(text="Processes: " + col.label(text="Processes: " +
", ".join([p.name for p in Processes.running_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): def draw_file_list(context, layout):
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
repo = prefs.active_repo repo = prefs.active_repo
@ -196,20 +268,25 @@ def draw_file_list(context, layout):
row.label(text="Repository is not authenticated.", icon='ERROR') row.label(text="Repository is not authenticated.", icon='ERROR')
return return
main_col = layout.column() width = get_sidebar_width(context)
main_row = main_col.row() layout.label(text=str(width))
split = main_row.split(factor=0.6)
filepath_row = split.row()
filepath_row.label(text=" Filepath")
status_row = split.row() top_row = layout.row()
status_row.label(text=" Status") 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() if repo.show_file_size:
ops_row.alignment = 'RIGHT' filesize_ui.label(text="Size")
ops_row.label(text="Operations") 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( row.template_list(
"SVN_UL_file_list", "SVN_UL_file_list",
"svn_file_list", "svn_file_list",
@ -221,14 +298,28 @@ def draw_file_list(context, layout):
col = row.column() col = row.column()
col.popover(
panel="SVN_PT_filelist_options",
text="",
icon='PREFERENCES',
)
col.separator() col.separator()
col.operator("svn.commit", icon='EXPORT', text="") col.operator("svn.commit", icon='EXPORT', text="")
if not prefs.do_auto_updates:
col.operator("svn.update_all", icon='IMPORT', text="").revision = 0 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.separator()
col.operator("svn.cleanup", icon='BRUSH_DATA', text="") col.operator("svn.cleanup", icon='BRUSH_DATA', text="")
registry = [ registry = [
SVN_UL_file_list, SVN_UL_file_list,
SVN_PT_filelist_options,
] ]

View File

@ -53,8 +53,8 @@ class VIEW3D_PT_svn_files(Panel):
layout.use_property_split = True layout.use_property_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
draw_process_info(context, layout)
draw_file_list(context, layout) draw_file_list(context, layout)
draw_process_info(context, layout)
registry = [ registry = [