SVN: UX improvements #136

Merged
Demeter Dzadik merged 15 commits from Mets/blender-studio-pipeline:svn_ux_improvements into main 2023-08-01 15:39:18 +02:00
20 changed files with 456 additions and 449 deletions

View File

@ -20,7 +20,7 @@ class SVN_Operator:
@staticmethod @staticmethod
def update_file_list(context): def update_file_list(context):
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
repo.update_file_filter(context) repo.refresh_ui_lists(context)
def execute_svn_command(self, context, command: List[str], use_cred=False) -> str: def execute_svn_command(self, context, command: List[str], use_cred=False) -> str:
# Since a status update might already be being requested when an SVN operator is run, # Since a status update might already be being requested when an SVN operator is run,
@ -51,7 +51,6 @@ class SVN_Operator_Single_File(SVN_Operator):
ret = self._execute(context) ret = self._execute(context)
file = self.get_file(context) file = self.get_file(context)
if file:
Processes.start('Status') Processes.start('Status')
redraw_viewport() redraw_viewport()
@ -107,7 +106,7 @@ class May_Modifiy_Current_Blend(SVN_Operator_Single_File, Warning_Operator):
return current_blend and current_blend.svn_path == self.file_rel_path return current_blend and current_blend.svn_path == self.file_rel_path
reload_file: BoolProperty( reload_file: BoolProperty(
name="Reload File", name="Reload File (Keep UI)",
description="Reload the file after the operation is completed. The UI layout will be preserved", description="Reload the file after the operation is completed. The UI layout will be preserved",
default=False, default=False,
) )
@ -133,6 +132,8 @@ class May_Modifiy_Current_Blend(SVN_Operator_Single_File, Warning_Operator):
super().execute(context) super().execute(context)
if self.reload_file: if self.reload_file:
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False) bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False)
else:
context.scene.svn.file_is_outdated = True
return {'FINISHED'} return {'FINISHED'}
@ -178,7 +179,7 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
missing_file_allowed = True missing_file_allowed = True
revision: IntProperty() revision: IntProperty(default=0)
def invoke(self, context, event): def invoke(self, context, event):
file_entry = context.scene.svn.get_repo( file_entry = context.scene.svn.get_repo(
@ -199,18 +200,24 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
"Cancelled: You have local modifications to this file. You must revert or commit it first!") "Cancelled: You have local modifications to this file. You must revert or commit it first!")
return {'CANCELLED'} return {'CANCELLED'}
self.execute_svn_command( self.svn_download_file_revision(context, self.file_rel_path, self.revision)
context,
["svn", "up", f"-r{self.revision}",
f"{self.file_rel_path}", "--accept", "postpone"],
use_cred=True
)
self.report({'INFO'}, self.report({'INFO'},
f"Checked out revision {self.revision} of {self.file_rel_path}") f"Checked out revision {self.revision} of {self.file_rel_path}")
return {"FINISHED"} return {"FINISHED"}
def svn_download_file_revision(self, context, svn_file_path: str, revision=0):
commands = ["svn", "up", f"{self.file_rel_path}", "--accept", "postpone"]
if self.revision > 0:
commands.insert(2, f"-r{self.revision}")
self.execute_svn_command(
context,
commands,
use_cred=True
)
def set_predicted_file_status(self, repo, file_entry: "SVN_file"): def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
file_entry['revision'] = self.revision file_entry['revision'] = self.revision
latest_rev = repo.get_latest_revision_of_file(self.file_rel_path) latest_rev = repo.get_latest_revision_of_file(self.file_rel_path)
@ -230,13 +237,14 @@ class SVN_OT_restore_file(May_Modifiy_Current_Blend, Operator):
missing_file_allowed = True missing_file_allowed = True
def _execute(self, context: Context) -> Set[str]: def svn_revert(self, context, svn_file_path):
self.execute_svn_command( self.execute_svn_command(
context, context,
["svn", "revert", f"{self.file_rel_path}"] ["svn", "revert", f"{svn_file_path}"]
) )
f = self.get_file(context) def _execute(self, context: Context) -> Set[str]:
self.svn_revert(context, self.file_rel_path)
return {"FINISHED"} return {"FINISHED"}
def set_predicted_file_status(self, repo, file_entry: "SVN_file"): def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
@ -251,15 +259,35 @@ class SVN_OT_revert_file(SVN_OT_restore_file):
missing_file_allowed = False missing_file_allowed = False
def _execute(self, context: Context) -> Set[str]:
super()._execute(context)
return {"FINISHED"}
def get_warning_text(self, context) -> str: def get_warning_text(self, context) -> str:
return "You will irreversibly and permanently lose the changes you've made to this file:\n " + self.file_rel_path return "You will irreversibly and permanently lose the changes you've made to this file:\n " + self.file_rel_path
class SVN_OT_revert_and_update(SVN_OT_download_file_revision, SVN_OT_revert_file):
"""Convenience operator for the "This file is outdated" warning message. Normally, these two operations should be done separately!"""
bl_idname = "svn.revert_and_update_file"
bl_label = "Revert And Update File"
bl_description = "A different version of this file was downloaded while it was open. This warning will persist until the file is updated and reloaded, or committed. Click to PERMANENTLY DISCARD local changes to this file and update it to the latest revision. Cannot be undone"
bl_options = {'INTERNAL'}
missing_file_allowed = False
def invoke(self, context, event):
return super(May_Modifiy_Current_Blend, self).invoke(context, event)
def get_warning_text(self, context) -> str:
if self.get_file(context).status != 'normal':
return "You will irreversibly and permanently lose the changes you've made to this file:\n " + self.file_rel_path
else:
return "File will be updated to latest revision."
def _execute(self, context: Context) -> Set[str]:
self.svn_revert(context, self.file_rel_path)
self.svn_download_file_revision(context, self.file_rel_path, self.revision)
return {"FINISHED"}
class SVN_OT_add_file(SVN_Operator_Single_File, Operator): class SVN_OT_add_file(SVN_Operator_Single_File, Operator):
bl_idname = "svn.add_file" bl_idname = "svn.add_file"
bl_label = "Add File" bl_label = "Add File"
@ -305,6 +333,7 @@ class SVN_OT_trash_file(SVN_Operator_Single_File, Warning_Operator, Operator):
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
file_rel_path: StringProperty() file_rel_path: StringProperty()
missing_file_allowed = False
def get_warning_text(self, context): def get_warning_text(self, context):
return "Are you sure you want to move this file to the recycle bin?\n " + self.file_rel_path return "Are you sure you want to move this file to the recycle bin?\n " + self.file_rel_path
@ -369,6 +398,7 @@ class SVN_OT_resolve_conflict(May_Modifiy_Current_Blend, Operator):
col.alert = True col.alert = True
col.label(text="Choose which version of the file to keep.") col.label(text="Choose which version of the file to keep.")
col.row().prop(self, 'resolve_method', expand=True) col.row().prop(self, 'resolve_method', expand=True)
col.separator()
if self.resolve_method == 'mine-full': if self.resolve_method == 'mine-full':
col.label(text="Local changes will be kept.") col.label(text="Local changes will be kept.")
col.label( col.label(
@ -411,15 +441,14 @@ class SVN_OT_cleanup(SVN_Operator, Operator):
self.execute_svn_command(context, ["svn", "cleanup"]) self.execute_svn_command(context, ["svn", "cleanup"])
repo.reload_svn_log(context) repo.reload_svn_log(context)
Processes.kill('Status')
Processes.kill('Log')
Processes.kill('Commit') Processes.kill('Commit')
Processes.kill('Update') Processes.kill('Update')
Processes.kill('Authenticate') Processes.kill('Authenticate')
Processes.kill('Activate File') Processes.kill('Activate File')
Processes.start('Status') Processes.restart('Status')
Processes.start('Log') Processes.restart('Log')
Processes.restart('Redraw Viewport')
self.report({'INFO'}, "SVN Cleanup complete.") self.report({'INFO'}, "SVN Cleanup complete.")
@ -428,13 +457,14 @@ class SVN_OT_cleanup(SVN_Operator, Operator):
registry = [ registry = [
SVN_OT_update_single, SVN_OT_update_single,
SVN_OT_download_file_revision, SVN_OT_revert_and_update,
SVN_OT_revert_file,
SVN_OT_restore_file, SVN_OT_restore_file,
SVN_OT_unadd_file, SVN_OT_revert_file,
SVN_OT_download_file_revision,
SVN_OT_add_file, SVN_OT_add_file,
SVN_OT_unadd_file,
SVN_OT_trash_file, SVN_OT_trash_file,
SVN_OT_remove_file, SVN_OT_remove_file,
SVN_OT_cleanup,
SVN_OT_resolve_conflict, SVN_OT_resolve_conflict,
SVN_OT_cleanup,
] ]

View File

@ -28,7 +28,6 @@ class SVN_OT_checkout_initiate(Operator):
def execute(self, context): def execute(self, context):
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
prefs.active_repo_mode = 'SELECTED_REPO'
if self.create: if self.create:
prefs.repositories.add() prefs.repositories.add()
prefs.active_repo_idx = len(prefs.repositories)-1 prefs.active_repo_idx = len(prefs.repositories)-1

View File

@ -40,6 +40,8 @@ class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
bl_property = "first_line" # Focus the text input box bl_property = "first_line" # Focus the text input box
popup_width = 600
# The first line of the commit message needs to be an operator property in order # The first line of the commit message needs to be an operator property in order
# for us to be able to focus the input box automatically when the window pops up # for us to be able to focus the input box automatically when the window pops up
# (see bl_property above) # (see bl_property above)
@ -92,6 +94,7 @@ class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
for f in repo.external_files: for f in repo.external_files:
f.include_in_commit = False f.include_in_commit = False
for f in self.get_committable_files(context): for f in self.get_committable_files(context):
if not f.will_conflict:
f.include_in_commit = True f.include_in_commit = True
return super().invoke(context, event) return super().invoke(context, event)
@ -108,20 +111,30 @@ class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
row.label(text="Status") row.label(text="Status")
for file in files: for file in files:
row = layout.row() row = layout.row()
row.prop(file, "include_in_commit", text=file.name) split = row.split()
checkbox_ui = split.row()
status_ui = split.row()
checkbox_ui.prop(file, "include_in_commit", text=file.name)
text = file.status_name text = file.status_name
icon = file.status_icon icon = file.status_icon
if file == repo.current_blend_file and self.is_file_really_dirty: if file.will_conflict:
split = row.split(factor=0.7) # We don't want to conflict-resolve during a commit, it's
row = split.row() # confusing. User should resolve this as a separate step.
row.alert = True checkbox_ui.enabled = False
text = "Conflicting"
status_ui.alert = True
icon = 'ERROR'
elif file == repo.current_blend_file and self.is_file_really_dirty:
split = status_ui.split(factor=0.7)
status_ui = split.row()
status_ui.alert = True
text += " but not saved!" text += " but not saved!"
icon = 'ERROR' icon = 'ERROR'
op_row = split.row() op_row = split.row()
op_row.alignment = 'LEFT' op_row.alignment = 'LEFT'
op_row.operator('svn.save_during_commit', op_row.operator('svn.save_during_commit',
icon='FILE_BLEND', text="Save") icon='FILE_BLEND', text="Save")
row.label(text=text, icon=icon) status_ui.label(text=text, icon=icon)
row = layout.row() row = layout.row()
row.label(text="Commit message:") row.label(text="Commit message:")
@ -142,7 +155,6 @@ class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
def execute(self, context: Context) -> Set[str]: def execute(self, context: Context) -> Set[str]:
committable_files = self.get_committable_files(context) committable_files = self.get_committable_files(context)
files_to_commit = [f for f in committable_files if f.include_in_commit] files_to_commit = [f for f in committable_files if f.include_in_commit]
prefs = get_addon_prefs(context)
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
if not files_to_commit: if not files_to_commit:

View File

@ -44,11 +44,14 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
current_blend = repo.current_blend_file current_blend = repo.current_blend_file
if self.revision == 0: if self.revision == 0:
if current_blend and current_blend.repos_status != 'none': if current_blend and current_blend.repos_status != 'none':
# If the current file will be modified, warn user.
self.file_rel_path = current_blend.svn_path self.file_rel_path = current_blend.svn_path
return context.window_manager.invoke_props_dialog(self, width=500) return context.window_manager.invoke_props_dialog(self, width=500)
else: else:
for f in repo.external_files: for f in repo.external_files:
if f.status in ['modified', 'added', 'conflicted', 'deleted', 'missing', 'unversioned']: if f.status in ['modified', 'added', 'conflicted', 'deleted', 'missing', 'unversioned']:
# If user wants to check out an older version of the repo but
# there are uncommitted local changes to any files, warn user.
return context.window_manager.invoke_props_dialog(self, width=500) return context.window_manager.invoke_props_dialog(self, width=500)
return self.execute(context) return self.execute(context)

View File

@ -3,12 +3,13 @@
# (c) 2022, Blender Foundation - Demeter Dzadik # (c) 2022, Blender Foundation - Demeter Dzadik
from typing import Optional, Any, Set, Tuple, List from typing import Optional, Any, Set, Tuple, List
import platform
import bpy import bpy
from bpy.props import IntProperty, CollectionProperty, BoolProperty, EnumProperty from bpy.props import IntProperty, CollectionProperty, BoolProperty, EnumProperty
from bpy.types import AddonPreferences from bpy.types import AddonPreferences
from .ui import ui_prefs from .ui.ui_repo_list import draw_checkout, draw_repo_list
from .repository import SVN_repository from .repository import SVN_repository
from .svn_info import get_svn_info from .svn_info import get_svn_info
import json import json
@ -19,8 +20,25 @@ from .threaded.background_process import Processes
class SVN_addon_preferences(AddonPreferences): class SVN_addon_preferences(AddonPreferences):
bl_idname = __package__ bl_idname = __package__
is_svn_installed: BoolProperty(
name="Is SVN Installed",
description="Whether the `svn` command works at all in the user's command line. If not, user needs to install SVN",
default=False
)
repositories: CollectionProperty(type=SVN_repository) repositories: CollectionProperty(type=SVN_repository)
def init_repo_list(self):
# If we have any repository entries, make sure at least one is active.
self.sync_repo_info_file()
if self.active_repo_idx == -1 and len(self.repositories) > 0:
self.active_repo_idx = 0
elif self.active_repo_idx > len(self.repositories)-1:
self.active_repo_idx = 0
else:
self.active_repo_idx = self.active_repo_idx
def init_repo(self, context, repo_path: Path or str): def init_repo(self, context, repo_path: Path or str):
"""Attempt to initialize a repository based on a directory. """Attempt to initialize a repository based on a directory.
This means executing `svn info` in the repo_path to get the URL and root dir. This means executing `svn info` in the repo_path to get the URL and root dir.
@ -40,70 +58,45 @@ class SVN_addon_preferences(AddonPreferences):
repo = self.repositories.add() repo = self.repositories.add()
repo.initialize(root_dir, base_url) repo.initialize(root_dir, base_url)
self.active_repo_idx = len(self.repositories)-1
return repo return repo
def update_active_repo_idx(self, context):
if self.idx_updating or len(self.repositories) == 0:
return
self.idx_updating = True
active_repo = self.active_repo
if self.active_repo_mode == 'CURRENT_BLEND':
scene_svn = context.scene.svn
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
if scene_svn_idx == -1:
self.idx_updating = False
return
self.active_repo_idx = scene_svn_idx
self.idx_updating = False
return
if (
active_repo and
not active_repo.authenticated and
not active_repo.auth_failed and
active_repo.is_cred_entered
):
active_repo.authenticate(context)
self.idx_updating = False
def update_active_repo_mode(self, context):
if self.active_repo_mode == 'CURRENT_BLEND':
scene_svn = context.scene.svn
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
self.active_repo_idx = scene_svn_idx
checkout_mode: BoolProperty( checkout_mode: BoolProperty(
name="Checkout In Progress", name="Checkout In Progress",
description="Internal flag to indicate that the user is currently trying to create a new checkout", description="Internal flag to indicate that the user is currently trying to create a new checkout",
default=False default=False
) )
active_repo_mode: EnumProperty( def update_active_repo_idx(self, context):
name="Choose Repository", if len(self.repositories) == 0:
description="Whether the add-on should communicate with the repository of the currently opened .blend file, or the repository selected in the list below", return
items=[ active_repo = self.active_repo
('CURRENT_BLEND', "Current Blend", "Check if the current .blend file is in an SVN repository, and communicate with that if that is the case. The file list will display only the files of the repository of the current .blend file. If the current .blend is not in a repository, do nothing"),
('SELECTED_REPO', "Selected Repo", # Authenticate when switching repos.
"Communicate with the selected repository") if (
], active_repo and
default='CURRENT_BLEND', not active_repo.auth_failed and
update=update_active_repo_mode active_repo.is_cred_entered
) ):
Processes.start('Redraw Viewport')
if active_repo.authenticated:
Processes.restart('Status')
else:
active_repo.authenticate()
else:
Processes.kill('Status')
active_repo_idx: IntProperty( active_repo_idx: IntProperty(
name="SVN Repositories", name="SVN Repositories",
options=set(), options=set(),
update=update_active_repo_idx update=update_active_repo_idx
) )
idx_updating: BoolProperty(
name="Index is Updating",
description="Helper flag to avoid infinite looping update callbacks",
)
@property @property
def active_repo(self) -> SVN_repository: def active_repo(self) -> Optional[SVN_repository]:
if not self.is_svn_installed:
return
if 0 <= self.active_repo_idx <= len(self.repositories)-1: if 0 <= self.active_repo_idx <= len(self.repositories)-1:
return self.repositories[self.active_repo_idx] return self.repositories[self.active_repo_idx]
@ -163,7 +156,33 @@ class SVN_addon_preferences(AddonPreferences):
self.load_repo_info_from_file() self.load_repo_info_from_file()
self.save_repo_info_to_file() self.save_repo_info_to_file()
draw = ui_prefs.draw_prefs def draw(self, context):
if not self.is_svn_installed:
draw_prefs_no_svn(self, context)
return
if self.checkout_mode:
draw_checkout(self, context)
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()
if system == "Windows":
terminal = "command line (cmd.exe)"
url = "https://subversion.apache.org/packages.html#windows"
elif system == "Darwin":
terminal = "Mac terminal"
url = "https://subversion.apache.org/packages.html#osx"
layout = self.layout
col = layout.column()
col.alert=True
col.label(text="Please ensure that Subversion (aka. SVN) is installed on your system.")
col.label(text=f"Typing `svn` into the {terminal} should yield a result.")
layout.operator("wm.url_open", icon='URL', text='Open Subversion Distribution Page').url=url
registry = [ registry = [

View File

@ -3,10 +3,9 @@
# (c) 2022, Blender Foundation - Demeter Dzadik # (c) 2022, Blender Foundation - Demeter Dzadik
from .util import get_addon_prefs from .util import get_addon_prefs
from bpy.props import StringProperty, PointerProperty from bpy.props import StringProperty, PointerProperty, BoolProperty
from bpy.types import PropertyGroup from bpy.types import PropertyGroup
import bpy import bpy
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Set from typing import Optional, Dict, Any, List, Tuple, Set
from . import wheels from . import wheels
# This will load the dateutil and BAT wheel files. # This will load the dateutil and BAT wheel files.
@ -27,27 +26,17 @@ class SVN_scene_properties(PropertyGroup):
description="Absolute directory path of the SVN repository's root in the file system", description="Absolute directory path of the SVN repository's root in the file system",
) )
def get_repo(self, context): file_is_outdated: BoolProperty(
"""Return the current repository. name="File Is Outdated",
Depending on preferences, this is either the repo the current .blend file is in, description="Set to True when downloading a newer version of this file without reloading it, so that the warning in the UI can persist. This won't work in some cases involving multiple running Blender instances",
or whatever repo is selected in the preferences UI. default=False
""" )
prefs = get_addon_prefs(context)
if prefs.active_repo_mode == 'CURRENT_BLEND': def get_repo(self, context) -> Optional['SVN_repository']:
return self.get_scene_repo(context) """Return the active repository."""
else: prefs = get_addon_prefs(context)
return prefs.active_repo return prefs.active_repo
def get_scene_repo(self, context) -> Optional['SVN_repository']:
if not self.svn_url or not self.svn_directory:
return
prefs = get_addon_prefs(context)
for repo in prefs.repositories:
if (repo.url == self.svn_url) and (Path(repo.directory) == Path(self.svn_directory)):
return repo
registry = [ registry = [
SVN_scene_properties, SVN_scene_properties,

View File

@ -48,6 +48,10 @@ class SVN_file(PropertyGroup):
default="none", default="none",
options=set() options=set()
) )
@property
def will_conflict(self):
return self.status != 'normal' and self.repos_status != 'none'
status_prediction_type: EnumProperty( status_prediction_type: EnumProperty(
name="Status Predicted By Process", name="Status Predicted By Process",
items=[ items=[
@ -68,18 +72,6 @@ class SVN_file(PropertyGroup):
options=set() options=set()
) )
@property
def absolute_path(self) -> Path:
"""Return absolute path on the file system."""
scene = self.id_data
svn = scene.svn
return Path(svn.svn_directory).joinpath(Path(self.svn_path))
@property
def relative_path(self) -> str:
"""Return relative path with Blender's path conventions."""
return bpy.path.relpath(self.absolute_path)
@property @property
def is_outdated(self): def is_outdated(self):
return self.repos_status == 'modified' and self.status == 'normal' return self.repos_status == 'modified' and self.status == 'normal'
@ -247,6 +239,7 @@ class SVN_repository(PropertyGroup):
@property @property
def is_valid_svn(self): def is_valid_svn(self):
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?
root_dir, base_url = get_svn_info(self.directory) root_dir, base_url = get_svn_info(self.directory)
return ( return (
dir_path.exists() and dir_path.exists() and
@ -282,10 +275,10 @@ class SVN_repository(PropertyGroup):
if get_addon_prefs(context).loading: if get_addon_prefs(context).loading:
return return
self.authenticate(context) self.authenticate()
self.update_repo_info_file(context) self.update_repo_info_file(context)
def authenticate(self, context): def authenticate(self):
self.auth_failed = False self.auth_failed = False
if self.is_valid_svn and self.is_cred_entered: if self.is_valid_svn and self.is_cred_entered:
Processes.start('Authenticate') Processes.start('Authenticate')
@ -305,9 +298,9 @@ class SVN_repository(PropertyGroup):
) )
@property @property
def is_cred_entered(self): def is_cred_entered(self) -> bool:
"""Check if there's a username and password entered at all.""" """Check if there's a username and password entered at all."""
return self.username and self.password return bool(self.username and self.password)
authenticated: BoolProperty( authenticated: BoolProperty(
name="Authenticated", name="Authenticated",
@ -316,7 +309,7 @@ class SVN_repository(PropertyGroup):
) )
auth_failed: BoolProperty( auth_failed: BoolProperty(
name="Authentication Failed", name="Authentication Failed",
description="Internal flag to mark whether the last entered credentials were denied by the repo", description="Internal flag to mark whether the last entered credentials were rejected by the repo",
default=False default=False
) )
@ -342,10 +335,6 @@ class SVN_repository(PropertyGroup):
name="SVN Log", name="SVN Log",
options=set() options=set()
) )
log_active_index_filebrowser: IntProperty(
name="SVN Log",
options=set()
)
reload_svn_log = svn_log.reload_svn_log reload_svn_log = svn_log.reload_svn_log
@ -360,19 +349,13 @@ class SVN_repository(PropertyGroup):
except IndexError: except IndexError:
return None return None
@property
def active_log_filebrowser(self):
try:
return self.log[self.log_active_index_filebrowser]
except IndexError:
return None
def get_log_by_revision(self, revision: int) -> Tuple[int, SVN_log]: def get_log_by_revision(self, revision: int) -> Tuple[int, SVN_log]:
for i, log in enumerate(self.log): for i, log in enumerate(self.log):
if log.revision_number == revision: if log.revision_number == revision:
return i, log return i, log
def get_latest_revision_of_file(self, svn_path: str) -> int: def get_latest_revision_of_file(self, svn_path: str) -> int:
"""Return the revision number of the last log entry that affects the given file."""
svn_path = str(svn_path) svn_path = str(svn_path)
for log in reversed(self.log): for log in reversed(self.log):
for changed_file in log.changed_files: for changed_file in log.changed_files:
@ -442,33 +425,43 @@ class SVN_repository(PropertyGroup):
def update_active_file(self, context): def update_active_file(self, context):
"""When user clicks on a different file, the latest log entry of that file """When user clicks on a different file, the latest log entry of that file
should become the active log entry.""" should become the active log entry.
NOTE: Try to only trigger this on explicit user actions!
"""
latest_idx = self.get_latest_revision_of_file( if self.external_files_active_index == self.prev_external_files_active_index:
return
self.prev_external_files_active_index = self.external_files_active_index
latest_rev = self.get_latest_revision_of_file(
self.active_file.svn_path) self.active_file.svn_path)
# SVN Revisions are not 0-indexed, so we need to subtract 1. # SVN Revisions are not 0-indexed, so we need to subtract 1.
self.log_active_index = latest_idx-1 self.log_active_index = latest_rev-1
space = context.space_data space = context.space_data
if space and space.type == 'FILE_BROWSER': if space and space.type == 'FILE_BROWSER':
# Set the active file in the file browser to whatever was selected in the SVN Files panel. space.params.directory = Path(self.active_file.absolute_path).parent.as_posix().encode()
self.log_active_index_filebrowser = latest_idx-1
space.params.directory = self.active_file.absolute_path.parent.as_posix().encode()
space.params.filename = self.active_file.name.encode() space.params.filename = self.active_file.name.encode()
space.deselect_all() space.deselect_all()
space.activate_file_by_relative_path( # Set the active file in the file browser to whatever was selected
# in the SVN Files panel.
space.activate_file_by_relative_path( # This doesn't actually work, due to what I assume is a bug.
relative_path=self.active_file.name) relative_path=self.active_file.name)
Processes.start('Activate File') Processes.start('Activate File') # This is my work-around.
# Filter out log entries that did not affect the selected file. # Set the filter flag of the log entries based on whether they affect the active file or not.
self.log.foreach_set( self.log.foreach_set(
'affects_active_file', 'affects_active_file',
[log_entry.changes_file(self.active_file) [log_entry.changes_file(self.active_file)
for log_entry in self.log] for log_entry in self.log]
) )
prev_external_files_active_index: IntProperty(
name="Previous Active Index",
description="Internal value to avoid triggering the update callback unnecessarily",
options=set()
)
external_files_active_index: IntProperty( external_files_active_index: IntProperty(
name="File List", name="File List",
description="Files tracked by SVN", description="Files tracked by SVN",
@ -513,28 +506,10 @@ class SVN_repository(PropertyGroup):
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 ###
# Filtering properties are normally stored on the UIList, def refresh_ui_lists(self, context):
# but then they cannot be accessed from anywhere else, """Refresh the file UI list based on filter settings.
# since template_list() does not return the UIList instance. Also triggers a refresh of the SVN UIList, through the update callback of
# We need to be able to access them outside of drawing code, to be able to external_files_active_index."""
# ensure that a filtered out entry can never be the active one.
def force_good_active_index(self, context) -> bool:
"""
We want to avoid having the active file entry be invisible due to filtering.
If the active element is being filtered out, set the active element to
something that is visible.
"""
if len(self.external_files) == 0:
return
if not self.active_file.show_in_filelist:
for i, file in enumerate(self.external_files):
if file.show_in_filelist:
self.external_files_active_index = i
return
def update_file_filter(self, context):
"""Should run when any of the SVN file list search filters are changed."""
UI_LIST = bpy.types.UI_UL_list UI_LIST = bpy.types.UI_UL_list
if self.file_search_filter: if self.file_search_filter:
@ -555,12 +530,20 @@ class SVN_repository(PropertyGroup):
file.show_in_filelist = not file.has_default_status file.show_in_filelist = not file.has_default_status
self.force_good_active_index(context) if len(self.external_files) == 0:
return
# Make sure the active file isn't now being filtered out.
# If it is, change the active file to the first visible one.
for i, file in enumerate(self.external_files):
if file.show_in_filelist:
self.external_files_active_index = i
return
file_search_filter: StringProperty( file_search_filter: StringProperty(
name="Search Filter", name="Search Filter",
description="Only show entries that contain this string", description="Only show entries that contain this string",
update=update_file_filter update=refresh_ui_lists
) )

View File

@ -44,7 +44,7 @@ class BackgroundProcess:
# Displayed in the tooltip on mouse-hover in the error message when an error occurs. # Displayed in the tooltip on mouse-hover in the error message when an error occurs.
error_description = "SVN Error:" error_description = "SVN Error:"
debug = True debug = False
def debug_print(self, msg: str): def debug_print(self, msg: str):
if self.debug: if self.debug:
@ -82,7 +82,7 @@ class BackgroundProcess:
def handle_error(self, context, error): def handle_error(self, context, error):
self.output = "" self.output = ""
self.error = error.stderr.decode() self.error = error.stderr.decode()
self.is_running = False self.stop()
def process_output(self, context, prefs): def process_output(self, context, prefs):
""" """
@ -113,7 +113,7 @@ class BackgroundProcess:
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
if not repo: if not repo:
self.debug_print("Shutdown: Not in repo.") self.debug_print("Shutdown: Not in repo.")
self.is_running = False self.stop()
return return
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
@ -127,7 +127,7 @@ class BackgroundProcess:
if self.needs_authentication and not repo.authenticated: if self.needs_authentication and not repo.authenticated:
self.debug_print("Shutdown: Authentication needed.") self.debug_print("Shutdown: Authentication needed.")
self.is_running = False self.stop()
return return
if not self.thread or not self.thread.is_alive() and not self.output and not self.error: if not self.thread or not self.thread.is_alive() and not self.output and not self.error:
@ -146,15 +146,14 @@ class BackgroundProcess:
self.output = "" self.output = ""
redraw_viewport() redraw_viewport()
if self.repeat_delay == 0: if self.repeat_delay == 0:
self.debug_print( self.debug_print("Shutdown: Output was processed, repeat_delay==0.")
"Shutdown: Output was processed, repeat_delay==0.") self.stop()
self.is_running = False
return return
self.debug_print(f"Processed output. Waiting {self.repeat_delay}") self.debug_print(f"Processed output. Waiting {self.repeat_delay}")
return self.repeat_delay return self.repeat_delay
elif not self.thread and not self.thread.is_alive() and self.repeat_delay == 0: elif not self.thread and not self.thread.is_alive() and self.repeat_delay == 0:
self.debug_print("Shutdown: Finished.\n") self.debug_print("Shutdown: Finished.\n")
self.is_running = False self.stop()
return return
self.debug_print(f"Tick delay: {self.tick_delay}") self.debug_print(f"Tick delay: {self.tick_delay}")
@ -188,6 +187,7 @@ class BackgroundProcess:
def stop(self): def stop(self):
"""Stop the process if it isn't running, by unregistering its timer function""" """Stop the process if it isn't running, by unregistering its timer function"""
self.debug_print("stop() function was called.")
self.is_running = False self.is_running = False
if bpy.app.timers.is_registered(self.timer_function): if bpy.app.timers.is_registered(self.timer_function):
# This won't work if the timer has returned None at any point, as that # This won't work if the timer has returned None at any point, as that
@ -214,6 +214,7 @@ class ProcessManager:
def processes(self): def processes(self):
# I tried to implement this thing as a Singleton that inherits from the `dict` class, # I tried to implement this thing as a Singleton that inherits from the `dict` class,
# I tried having the `processes` dict on the class level, # I tried having the `processes` dict on the class level,
# I tried having it on the instance level,
# and it just refuses to work properly. I add an instance to the dictionary, # and it just refuses to work properly. I add an instance to the dictionary,
# I print it, I can see that it's there, I make sure it absolutely doesn't get removed, # I print it, I can see that it's there, I make sure it absolutely doesn't get removed,
# but when I try to access it from anywhere, it's just empty. My mind is boggled. # but when I try to access it from anywhere, it's just empty. My mind is boggled.
@ -238,6 +239,8 @@ class ProcessManager:
process = self.processes.get(proc_name, None) process = self.processes.get(proc_name, None)
if process: if process:
process.start() process.start()
for key, value in kwargs.items():
setattr(process, key, value)
return return
else: else:
for subcl in get_recursive_subclasses(BackgroundProcess): for subcl in get_recursive_subclasses(BackgroundProcess):
@ -261,6 +264,12 @@ class ProcessManager:
process.stop() process.stop()
del self.processes[proc_name] del self.processes[proc_name]
def restart(self, proc_name: str):
"""Destroy a process, then start it again.
Useful to skip the repeat_delay timer of infinite processes like Status or Log."""
self.kill(proc_name)
self.start(proc_name)
# I named this variable with title-case, to indicate that it's a Singleton. # I named this variable with title-case, to indicate that it's a Singleton.
# There should only be one. # There should only be one.

View File

@ -51,6 +51,8 @@ class BGP_SVN_Commit(BackgroundProcess):
print(self.output) print(self.output)
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
for f in repo.external_files: for f in repo.external_files:
if f == repo.current_blend_file:
context.scene.file_is_outdated = False
if f.status_prediction_type == 'SVN_COMMIT': if f.status_prediction_type == 'SVN_COMMIT':
f.status_prediction_type = 'SKIP_ONCE' f.status_prediction_type = 'SKIP_ONCE'
Processes.start('Log') Processes.start('Log')

View File

@ -4,7 +4,6 @@
import subprocess import subprocess
from typing import List from typing import List
def get_credential_commands(context) -> List[str]: def get_credential_commands(context) -> List[str]:
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
assert (repo.is_cred_entered), "No username or password entered for this repository. The UI shouldn't have allowed you to get into a state where you can press an SVN operation button without having your credentials entered, so this is a bug!" assert (repo.is_cred_entered), "No username or password entered for this repository. The UI shouldn't have allowed you to get into a state where you can press an SVN operation button without having your credentials entered, so this is a bug!"
@ -29,6 +28,9 @@ def execute_svn_command(context, command: List[str], *, ignore_errors=False, pri
SVN root. SVN root.
""" """
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
if "svn" not in command:
command.insert(0, "svn")
if use_cred: if use_cred:
command += get_credential_commands(context) command += get_credential_commands(context)
@ -46,3 +48,7 @@ def execute_svn_command(context, command: List[str], *, ignore_errors=False, pri
print(f"Command returned error: {command}") print(f"Command returned error: {command}")
print(err_msg) print(err_msg)
raise error raise error
def check_svn_installed():
code, message = subprocess.getstatusoutput('svn')
return code != 127

View File

@ -7,6 +7,7 @@ class BGP_SVN_Redraw_Viewport(BackgroundProcess):
repeat_delay = 1 repeat_delay = 1
debug = False debug = False
tick_delay = 1 tick_delay = 1
needs_authentication = False
def tick(self, context, prefs): def tick(self, context, prefs):
redraw_viewport() redraw_viewport()

View File

@ -4,7 +4,7 @@
from ..svn_info import get_svn_info from ..svn_info import get_svn_info
from ..util import get_addon_prefs from ..util import get_addon_prefs
from .. import constants from .. import constants
from .execute_subprocess import execute_svn_command from .execute_subprocess import execute_svn_command, check_svn_installed
from .background_process import BackgroundProcess, Processes from .background_process import BackgroundProcess, Processes
from bpy.types import Operator from bpy.types import Operator
from bpy.props import StringProperty from bpy.props import StringProperty
@ -55,49 +55,44 @@ class SVN_OT_explain_status(Operator):
@bpy.app.handlers.persistent @bpy.app.handlers.persistent
def init_svn_of_current_file(_scene=None): def ensure_svn_of_current_file(_scene=None):
"""When opening or saving a .blend file: """When opening or saving a .blend file, it's possible that the new .blend
- Initialize SVN Scene info is part of an SVN repository. If this is the case, do the following:
- Initialize Repository - Check if this file's repository is already in our database
- Try to authenticate - If not, create it
- Switch to that repo
""" """
context = bpy.context context = bpy.context
prefs = get_addon_prefs(context)
prefs.is_svn_installed = check_svn_installed()
if not prefs.is_svn_installed:
return
scene_svn = context.scene.svn scene_svn = context.scene.svn
prefs = get_addon_prefs(context) old_active_repo = prefs.active_repo
prefs.sync_repo_info_file() prefs.init_repo_list()
for repo in prefs.repositories: # If the file is unsaved, nothing more to do.
# This would ideally only run when opening Blender for the first
# time, but there is no app handler for that, sadly.
repo.authenticated = False
repo.auth_failed = False
if prefs.active_repo_mode == 'CURRENT_BLEND':
if not bpy.data.filepath: if not bpy.data.filepath:
scene_svn.svn_url = "" scene_svn.svn_url = ""
return return
# If file is not in a repo, nothing more to do.
is_in_repo = set_scene_svn_info(context) is_in_repo = set_scene_svn_info(context)
if not is_in_repo: if not is_in_repo:
return return
repo = scene_svn.get_scene_repo(context) # If file is in an existing repo, we should switch over to that repo.
if not repo: for i, existing_repo in enumerate(prefs.repositories):
repo = prefs.init_repo(context, scene_svn.svn_directory) if ( existing_repo.url == scene_svn.svn_url and
existing_repo.directory == scene_svn.svn_directory and
for i, other_repo in enumerate(prefs.repositories): existing_repo != old_active_repo
if other_repo == repo: ):
prefs.active_repo_idx = i prefs.active_repo_idx = i
else: else:
repo = prefs.active_repo # If file is in a non-existing repo, initialize that repo.
if not repo: prefs.init_repo(context, scene_svn.svn_directory)
return
if repo.is_cred_entered:
repo.authenticate(context)
def set_scene_svn_info(context) -> bool: def set_scene_svn_info(context) -> bool:
@ -233,21 +228,6 @@ def update_file_list(context, file_statuses: Dict[str, Tuple[str, str, int]]):
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))
# if file_entry.status_prediction_type == 'SKIP_ONCE':
# # File status was predicted by a local svn file operation,
# # so we should ignore this status update and reset the flag.
# # The file status will be updated on the next status update.
# # This is because this status update was initiated before the file's
# # status was predicted, so the prediction is likely to be correct,
# # and the status we have here is likely to be outdated.
# file_entry.status_prediction_type = 'SKIPPED_ONCE'
# continue
# elif file_entry.status_prediction_type not in {'NONE', 'SKIPPED_ONCE'}:
# # We wait for `svn up/commit` background processes to finish and
# # set the predicted flag to SKIP_ONCE. Until then, we ignore status
# # updates on files that are being updated or committed.
# continue
if entry_existed and (file_entry.repos_status == 'none' and repos_status != 'none'): if entry_existed and (file_entry.repos_status == 'none' and repos_status != 'none'):
new_files_on_repo.add((file_entry.svn_path, repos_status)) new_files_on_repo.add((file_entry.svn_path, repos_status))
@ -277,8 +257,7 @@ def update_file_list(context, file_statuses: Dict[str, Tuple[str, str, int]]):
if file_entry.svn_path not in svn_paths: if file_entry.svn_path not in svn_paths:
repo.remove_file_entry(file_entry) repo.remove_file_entry(file_entry)
repo.update_file_filter(context) repo.refresh_ui_lists(context)
repo.force_good_active_index(context)
def get_repo_file_statuses(svn_status_str: str) -> Dict[str, Tuple[str, str, int]]: def get_repo_file_statuses(svn_status_str: str) -> Dict[str, Tuple[str, str, int]]:
@ -339,22 +318,22 @@ def mark_current_file_as_modified(_dummy1=None, _dummy2=None):
def delayed_init_svn(delay=1): def delayed_init_svn(delay=1):
bpy.app.timers.register(init_svn_of_current_file, first_interval=delay) bpy.app.timers.register(ensure_svn_of_current_file, first_interval=delay)
def register(): def register():
bpy.app.handlers.load_post.append(init_svn_of_current_file) bpy.app.handlers.load_post.append(ensure_svn_of_current_file)
bpy.app.handlers.save_post.append(init_svn_of_current_file) bpy.app.handlers.save_post.append(ensure_svn_of_current_file)
bpy.app.handlers.save_post.append(mark_current_file_as_modified) bpy.app.handlers.save_post.append(mark_current_file_as_modified)
delayed_init_svn() delayed_init_svn()
def unregister(): def unregister():
bpy.app.handlers.load_post.remove(init_svn_of_current_file) bpy.app.handlers.load_post.remove(ensure_svn_of_current_file)
bpy.app.handlers.save_post.remove(init_svn_of_current_file) bpy.app.handlers.save_post.remove(ensure_svn_of_current_file)
bpy.app.handlers.save_post.remove(mark_current_file_as_modified) bpy.app.handlers.save_post.remove(mark_current_file_as_modified)

View File

@ -3,7 +3,7 @@ from . import (
ui_sidebar, ui_sidebar,
ui_filebrowser, ui_filebrowser,
ui_log, ui_log,
ui_prefs, ui_repo_list,
ui_outdated_warning, ui_outdated_warning,
ui_context_menus ui_context_menus
) )
@ -13,7 +13,7 @@ modules = [
ui_sidebar, ui_sidebar,
ui_filebrowser, ui_filebrowser,
ui_log, ui_log,
ui_prefs, ui_repo_list,
ui_outdated_warning, ui_outdated_warning,
ui_context_menus ui_context_menus
] ]

View File

@ -3,26 +3,10 @@
import bpy import bpy
from bpy.types import Context, UIList, Operator from bpy.types import Context, UIList, Operator
from bpy.props import StringProperty from bpy.props import StringProperty, BoolProperty
from pathlib import Path from pathlib import Path
class SVN_OT_open_blend_file(Operator):
# This is needed because drawing a button for wm.open_mainfile in the UI
# directly simply does not work; Blender just opens a full-screen filebrowser,
# instead of opening the .blend file. Probably a bug.
bl_idname = "svn.open_blend_file"
bl_label = "Open Blend File"
bl_description = "Open Blend File"
bl_options = {'INTERNAL'}
filepath: StringProperty()
def execute(self, context):
bpy.ops.wm.open_mainfile(filepath=self.filepath, load_ui=False)
return {'FINISHED'}
def check_context_match(context: Context, uilayout_type: str, bl_idname: str) -> bool: def check_context_match(context: Context, uilayout_type: str, bl_idname: str) -> bool:
"""For example, when right-clicking on a UIList, the uilayout_type will """For example, when right-clicking on a UIList, the uilayout_type will
be `ui_list` and the bl_idname is that of the UIList being right-clicked. be `ui_list` and the bl_idname is that of the UIList being right-clicked.
@ -39,8 +23,17 @@ def svn_file_list_context_menu(self: UIList, context: Context) -> None:
layout.separator() layout.separator()
active_file = context.scene.svn.get_repo(context).active_file active_file = context.scene.svn.get_repo(context).active_file
if active_file.name.endswith("blend"): if active_file.name.endswith("blend"):
layout.operator("svn.open_blend_file", op = layout.operator("wm.open_mainfile",
text=f"Open {active_file.name}").filepath = active_file.absolute_path text=f"Open {active_file.name}")
op.filepath = active_file.absolute_path
op.display_file_selector = False
op.load_ui = True
op = layout.operator("wm.open_mainfile",
text=f"Open {active_file.name} (Keep UI)")
op.filepath = active_file.absolute_path
op.display_file_selector = 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(Path(active_file.absolute_path)) text=f"Open {active_file.name}").filepath = str(Path(active_file.absolute_path))
@ -53,12 +46,11 @@ def svn_log_list_context_menu(self: UIList, context: Context) -> None:
if not check_context_match(context, 'ui_list', 'SVN_UL_log'): if not check_context_match(context, 'ui_list', 'SVN_UL_log'):
return return
is_filebrowser = context.space_data.type == 'FILE_BROWSER'
layout = self.layout layout = self.layout
layout.separator() layout.separator()
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
active_log = repo.active_log_filebrowser if is_filebrowser else repo.active_log active_log = repo.active_log
layout.operator("svn.update_all", layout.operator("svn.update_all",
text=f"Revert Repository To r{active_log.revision_number}").revision = active_log.revision_number text=f"Revert Repository To r{active_log.revision_number}").revision = active_log.revision_number
layout.separator() layout.separator()
@ -73,5 +65,3 @@ def unregister():
bpy.types.UI_MT_list_item_context_menu.remove(svn_file_list_context_menu) bpy.types.UI_MT_list_item_context_menu.remove(svn_file_list_context_menu)
bpy.types.UI_MT_list_item_context_menu.remove(svn_log_list_context_menu) bpy.types.UI_MT_list_item_context_menu.remove(svn_log_list_context_menu)
registry = [SVN_OT_open_blend_file]

View File

@ -114,6 +114,7 @@ class SVN_UL_file_list(UIList):
explainer.status = status explainer.status = status
explainer.file_rel_path = file_entry.svn_path explainer.file_rel_path = file_entry.svn_path
@classmethod @classmethod
def cls_filter_items(cls, context, data, propname): def cls_filter_items(cls, context, data, propname):
"""By moving all of this logic to a classmethod (and all the filter """By moving all of this logic to a classmethod (and all the filter
@ -148,8 +149,10 @@ class SVN_UL_file_list(UIList):
row.prop(self, 'show_file_paths', text="", row.prop(self, 'show_file_paths', text="",
toggle=True, icon="FILE_FOLDER") toggle=True, icon="FILE_FOLDER")
row.prop(context.scene.svn.get_repo(context),
'file_search_filter', text="") repo = context.scene.svn.get_repo(context)
if repo:
row.prop(repo, 'file_search_filter', text="")
def draw_process_info(context, layout): def draw_process_info(context, layout):
@ -183,18 +186,19 @@ def draw_process_info(context, layout):
", ".join([p.name for p in Processes.running_processes])) ", ".join([p.name for p in Processes.running_processes]))
def draw_repo_file_list(context, layout, repo): def draw_file_list(context, layout):
prefs = get_addon_prefs(context)
repo = prefs.active_repo
if not repo: if not repo:
return return
if not repo.authenticated:
row = layout.row()
row.alert=True
row.label(text="Repository is not authenticated.", icon='ERROR')
return
main_col = layout.column() main_col = layout.column()
main_col.enabled = False
status_proc = Processes.get('Status')
time_since_last_update = 1000
if status_proc:
time_since_last_update = time.time() - status_proc.timestamp_last_update
if time_since_last_update < 30:
main_col.enabled = True
main_row = main_col.row() main_row = main_col.row()
split = main_row.split(factor=0.6) split = main_row.split(factor=0.6)
filepath_row = split.row() filepath_row = split.row()
@ -207,11 +211,6 @@ def draw_repo_file_list(context, layout, repo):
ops_row.alignment = 'RIGHT' ops_row.alignment = 'RIGHT'
ops_row.label(text="Operations") ops_row.label(text="Operations")
timer_row = main_row.row()
timer_row.alignment = 'RIGHT'
timer_row.operator("svn.custom_tooltip", icon='BLANK1', text="",
emboss=False).tooltip = "Time since last file status update: " + str(time_since_last_update) + 's'
row = main_col.row() row = main_col.row()
row.template_list( row.template_list(
"SVN_UL_file_list", "SVN_UL_file_list",

View File

@ -4,11 +4,10 @@
from bpy.types import Panel from bpy.types import Panel
from bl_ui.space_filebrowser import FileBrowserPanel from bl_ui.space_filebrowser import FileBrowserPanel
from .ui_log import draw_svn_log from .ui_log import draw_svn_log, is_log_useful
from .ui_file_list import draw_repo_file_list from .ui_file_list import draw_file_list
from ..util import get_addon_prefs from ..util import get_addon_prefs
class FILEBROWSER_PT_SVN_files(FileBrowserPanel, Panel): class FILEBROWSER_PT_SVN_files(FileBrowserPanel, Panel):
bl_space_type = 'FILE_BROWSER' bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOLS' bl_region_type = 'TOOLS'
@ -20,47 +19,37 @@ class FILEBROWSER_PT_SVN_files(FileBrowserPanel, Panel):
if not super().poll(context): if not super().poll(context):
return False return False
repo = context.scene.svn.get_repo(context) prefs = get_addon_prefs(context)
if not repo: return prefs.active_repo and prefs.active_repo.authenticated
return False
return repo.is_filebrowser_directory_in_repo(context)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.use_property_split = True layout.use_property_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
# TODO: Get repository of the current file browser's directory. draw_file_list(context, layout)
prefs = get_addon_prefs(context)
if len(prefs.repositories) > 0:
repo = prefs.active_repo
draw_repo_file_list(context, layout, repo)
class FILEBROWSER_PT_SVN_log(FileBrowserPanel, Panel): class FILEBROWSER_PT_SVN_log(FileBrowserPanel, Panel):
bl_space_type = 'FILE_BROWSER' bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOLS' bl_region_type = 'TOOLS'
bl_category = "Bookmarks" bl_category = "Bookmarks"
bl_label = "SVN Log" bl_parent_id = "FILEBROWSER_PT_SVN_files"
bl_label = "Revision History"
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
if not super().poll(context): if not super().poll(context):
return False return False
repo = context.scene.svn.get_repo(context) return is_log_useful(context)
if not repo:
return False
return repo.get_filebrowser_active_file(context)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.use_property_split = True layout.use_property_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
draw_svn_log(context, layout, file_browser=True) draw_svn_log(context, layout)
registry = [ registry = [

View File

@ -3,6 +3,7 @@
from bpy.props import IntProperty, BoolProperty from bpy.props import IntProperty, BoolProperty
from bpy.types import UIList, Panel, Operator from bpy.types import UIList, Panel, Operator
from ..util import get_addon_prefs
class SVN_UL_log(UIList): class SVN_UL_log(UIList):
@ -21,9 +22,7 @@ class SVN_UL_log(UIList):
num, auth, date, msg = layout_log_split(layout.row()) num, auth, date, msg = layout_log_split(layout.row())
is_filebrowser = context.space_data.type == 'FILE_BROWSER' active_file = svn.active_file
active_file = svn.get_filebrowser_active_file(
context) if is_filebrowser else svn.active_file
num.label(text=str(log_entry.revision_number)) num.label(text=str(log_entry.revision_number))
if item.revision_number == active_file.revision: if item.revision_number == active_file.revision:
num.operator('svn.tooltip_log', text="", icon='LAYER_ACTIVE', num.operator('svn.tooltip_log', text="", icon='LAYER_ACTIVE',
@ -88,8 +87,15 @@ class SVN_UL_log(UIList):
toggle=True, icon='ALIGN_JUSTIFY') toggle=True, icon='ALIGN_JUSTIFY')
def is_log_useful(context): def is_log_useful(context) -> bool:
repo = context.scene.svn.get_repo(context) """Return whether the log has any useful info to display."""
prefs = get_addon_prefs(context)
repo = prefs.active_repo
if not repo or not repo.authenticated:
return False
if len(repo.log) == 0 or len(repo.external_files) == 0: if len(repo.log) == 0 or len(repo.external_files) == 0:
return False return False
active_file = repo.active_file active_file = repo.active_file
@ -121,7 +127,7 @@ class VIEW3D_PT_svn_log(Panel):
layout.use_property_split = True layout.use_property_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
draw_svn_log(context, layout, file_browser=False) draw_svn_log(context, layout)
def layout_log_split(layout): def layout_log_split(layout):
@ -140,23 +146,25 @@ def layout_log_split(layout):
return num, auth, date, msg return num, auth, date, msg
def draw_svn_log(context, layout, file_browser: bool): def draw_svn_log(context, layout):
num, auth, date, msg = layout_log_split(layout.row()) num, auth, date, msg = layout_log_split(layout.row())
num.label(text="Rev. #") num.label(text="Rev. #")
auth.label(text="Author") auth.label(text="Author")
date.label(text="Date") date.label(text="Date")
msg.label(text="Message") msg.label(text="Message")
repo = context.scene.svn.get_repo(context)
prefs = get_addon_prefs(context)
repo = prefs.active_repo
layout.template_list( layout.template_list(
"SVN_UL_log", "SVN_UL_log",
"svn_log", "svn_log",
repo, repo,
"log", "log",
repo, repo,
"log_active_index_filebrowser" if file_browser else "log_active_index", "log_active_index",
) )
active_log = repo.active_log_filebrowser if file_browser else repo.active_log active_log = repo.active_log
if not active_log: if not active_log:
return return
layout.label(text="Revision Date: " + active_log.revision_date) layout.label(text="Revision Date: " + active_log.revision_date)
@ -187,9 +195,6 @@ def execute_tooltip_log(self, context):
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
tup = repo.get_log_by_revision(self.log_rev) tup = repo.get_log_by_revision(self.log_rev)
if tup: if tup:
if context.area.type == 'FILE_BROWSER':
repo.log_active_index_filebrowser = tup[0]
else:
repo.log_active_index = tup[0] repo.log_active_index = tup[0]
return {'FINISHED'} return {'FINISHED'}

View File

@ -17,9 +17,6 @@ def draw_outdated_file_warning(self, context):
# If the current file is not in an SVN repository. # If the current file is not in an SVN repository.
return return
if current_file.status == 'normal' and current_file.repos_status == 'none':
return
layout = self.layout layout = self.layout
row = layout.row() row = layout.row()
row.alert = True row.alert = True
@ -27,15 +24,14 @@ def draw_outdated_file_warning(self, context):
if current_file.status == 'conflicted': if current_file.status == 'conflicted':
row.operator('svn.resolve_conflict', row.operator('svn.resolve_conflict',
text="SVN: This .blend file is conflicted.", icon='ERROR') text="SVN: This .blend file is conflicted.", icon='ERROR')
elif current_file.repos_status != 'none': elif current_file.repos_status != 'none' or context.scene.svn.file_is_outdated:
warning = row.operator( op = row.operator('svn.revert_and_update_file', text="SVN: This .blend file may be outdated.", icon='ERROR')
'svn.custom_tooltip', text="SVN: This .blend file is outdated.", icon='ERROR') op.file_rel_path = repo.current_blend_file.svn_path
warning.tooltip = "The currently opened .blend file has a newer version available on the remote repository. This means any changes in this file will result in a conflict, and potential loss of data. See the SVN panel for info"
def register(): def register():
bpy.types.VIEW3D_HT_header.prepend(draw_outdated_file_warning) bpy.types.TOPBAR_MT_editor_menus.append(draw_outdated_file_warning)
def unregister(): def unregister():
bpy.types.VIEW3D_HT_header.remove(draw_outdated_file_warning) bpy.types.TOPBAR_MT_editor_menus.remove(draw_outdated_file_warning)

View File

@ -1,26 +1,25 @@
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# (c) 2023, Blender Foundation - Demeter Dzadik # (c) 2023, Blender Foundation - Demeter Dzadik
from pathlib import Path import platform
from bpy.types import UIList, Operator, Menu from bpy.types import UIList, Operator, Menu
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from ..util import get_addon_prefs from ..util import get_addon_prefs
from .ui_log import draw_svn_log, is_log_useful from .ui_log import draw_svn_log, is_log_useful
from .ui_file_list import draw_repo_file_list, draw_process_info from .ui_file_list import draw_file_list, draw_process_info
from ..threaded.background_process import Processes from ..threaded.background_process import Processes
import platform
from pathlib import Path
class SVN_UL_repositories(UIList): class SVN_UL_repositories(UIList):
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
):
repo = item repo = item
row = layout.row() row = layout.row()
prefs = get_addon_prefs(context)
if prefs.active_repo_mode == 'CURRENT_BLEND' and repo != context.scene.svn.get_repo(context):
row.enabled = False
row.label(text=repo.display_name) row.label(text=repo.display_name)
if not repo.dir_exists: if not repo.dir_exists:
@ -30,6 +29,7 @@ class SVN_UL_repositories(UIList):
class SVN_OT_repo_add(Operator, ImportHelper): class SVN_OT_repo_add(Operator, ImportHelper):
"""Add a repository to the list""" """Add a repository to the list"""
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
bl_idname = "svn.repo_add" bl_idname = "svn.repo_add"
@ -40,7 +40,16 @@ class SVN_OT_repo_add(Operator, ImportHelper):
repos = prefs.repositories repos = prefs.repositories
path = Path(self.filepath) path = Path(self.filepath)
if not path.exists():
# It's unlikely that a path that the user JUST BROWSED doesn't exist.
# So, this actually happens when the user leaves a filename in the
# file browser text box while trying to select the folder...
# Basically, Blender is dumb, and it will add that filename to the
# end of the browsed path. We need to discard that.
path = path.parent
if path.is_file(): if path.is_file():
# Maybe the user actually did select an existing file in the repo.
# We still want to discard the filename.
path = path.parent path = path.parent
existing_repos = repos[:] existing_repos = repos[:]
@ -48,7 +57,9 @@ class SVN_OT_repo_add(Operator, ImportHelper):
repo = prefs.init_repo(context, path) repo = prefs.init_repo(context, path)
except Exception as e: except Exception as e:
self.report( self.report(
{'ERROR'}, "Failed to initialize repository. Ensure you have SVN installed, and that the selected directory is the root of a repository.") {'ERROR'},
"Failed to initialize repository. Ensure you have SVN installed, and that the selected directory is the root of a repository.",
)
print(e) print(e)
return {'CANCELLED'} return {'CANCELLED'}
if not repo: if not repo:
@ -65,6 +76,7 @@ class SVN_OT_repo_add(Operator, ImportHelper):
class SVN_OT_repo_remove(Operator): class SVN_OT_repo_remove(Operator):
"""Remove a repository from the list""" """Remove a repository from the list"""
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
bl_idname = "svn.repo_remove" bl_idname = "svn.repo_remove"
@ -93,19 +105,98 @@ class SVN_MT_add_repo(Menu):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.operator( layout.operator(
"svn.repo_add", text="Browse Existing Checkout", icon='FILE_FOLDER') "svn.repo_add", text="Browse Existing Checkout", icon='FILE_FOLDER'
layout.operator("svn.checkout_initiate", )
text="Create New Checkout", icon='URL').create = True layout.operator(
"svn.checkout_initiate", text="Create New Checkout", icon='URL'
).create = True
def draw_prefs(self, context): def draw_repo_list(self, context) -> None:
if self.checkout_mode: layout = self.layout
draw_prefs_checkout(self, context)
else: auth_in_progress = False
draw_prefs_repos(self, context) auth_error = False
auth_proc = Processes.get('Authenticate')
if auth_proc:
auth_in_progress = auth_proc.is_running
auth_error = auth_proc.error
repo_col = layout.column()
split = repo_col.row().split()
split.row().label(text="SVN Repositories:")
# Secret debug toggle (invisible, to the right of the SVN Repositories label.)
row = split.row()
row.alignment = 'RIGHT'
row.prop(self, 'debug_mode', text="", icon='BLANK1', emboss=False)
repo_col.enabled = not auth_in_progress
list_row = repo_col.row()
col = list_row.column()
col.template_list(
"SVN_UL_repositories",
"svn_repo_list",
self,
"repositories",
self,
"active_repo_idx",
)
op_col = list_row.column()
op_col.menu('SVN_MT_add_repo', icon='ADD', text="")
op_col.operator('svn.repo_remove', icon='REMOVE', text="")
if len(self.repositories) == 0:
return
if self.active_repo_idx - 1 > len(self.repositories):
return
if not self.active_repo:
return
repo_col.prop(self.active_repo, 'display_name', icon='FILE_TEXT')
repo_col.prop(self.active_repo, 'url', icon='URL')
repo_col.prop(self.active_repo, 'username', icon='USER')
repo_col.prop(self.active_repo, 'password', icon='LOCKED')
draw_process_info(context, layout.row())
if not self.active_repo.dir_exists:
draw_repo_error(layout, "Repository not found on file system.")
return
if not self.active_repo.is_valid_svn:
draw_repo_error(layout, "Directory is not an SVN repository.")
split = layout.split(factor=0.24)
split.row()
split.row().operator(
"svn.checkout_initiate", text="Create New Checkout", icon='URL'
).create = False
return
if not self.active_repo.authenticated and not auth_in_progress and not auth_error:
draw_repo_error(layout, "Repository not authenticated. Enter your credentials.")
return
if len(self.repositories) > 0 and self.active_repo.authenticated:
layout.separator()
layout.label(text="SVN Files: ")
draw_file_list(context, layout)
if is_log_useful(context):
layout.separator()
layout.label(text="Revision History: ")
draw_svn_log(context, layout)
def draw_prefs_checkout(self, context): def draw_repo_error(layout, message):
split = layout.split(factor=0.24)
split.row()
col = split.column()
col.alert = True
col.label(text=message, icon='ERROR')
def draw_checkout(self, context):
def get_terminal_howto(): def get_terminal_howto():
msg_windows = "If you don't, cancel this operation and toggle it using Window->Toggle System Console." msg_windows = "If you don't, cancel this operation and toggle it using Window->Toggle System Console."
msg_linux = "If you don't, quit Blender and re-launch it from a terminal." msg_linux = "If you don't, quit Blender and re-launch it from a terminal."
@ -128,16 +219,22 @@ def draw_prefs_checkout(self, context):
col.label(text=get_terminal_howto()) col.label(text=get_terminal_howto())
col.separator() col.separator()
col.label( col.label(
text="Downloading a repository can take a long time, and the UI will be locked.") text="Downloading a repository can take a long time, and the UI will be locked."
)
col.label( col.label(
text="Without a terminal, you won't be able to track the progress of the checkout.") text="Without a terminal, you won't be able to track the progress of the checkout."
)
col.separator() col.separator()
col = layout.column() col = layout.column()
col.label( col.label(
text="To interrupt the checkout, you can press Ctrl+C in the terminal.", icon='INFO') text="To interrupt the checkout, you can press Ctrl+C in the terminal.",
icon='INFO',
)
col.label( col.label(
text="You can resume it by re-running this operation, or with the SVN Update button.", icon='INFO') text="You can resume it by re-running this operation, or with the SVN Update button.",
icon='INFO',
)
col.separator() col.separator()
prefs = get_addon_prefs(context) prefs = get_addon_prefs(context)
@ -150,7 +247,8 @@ def draw_prefs_checkout(self, context):
row = col.row() row = col.row()
row.alert = True row.alert = True
row.label( row.label(
text="A repository at this filepath is already specified.", icon='ERROR') text="A repository at this filepath is already specified.", icon='ERROR'
)
break break
col.prop(repo, 'display_name', text="Folder Name", icon='NEWFOLDER') col.prop(repo, 'display_name', text="Folder Name", icon='NEWFOLDER')
@ -163,7 +261,8 @@ def draw_prefs_checkout(self, context):
sub.alert = True sub.alert = True
sub.label(text="A repository with this URL is already specified.") sub.label(text="A repository with this URL is already specified.")
sub.label( sub.label(
text="If you're sure you want to checkout another copy of the repo, feel free to proceed.") text="If you're sure you want to checkout another copy of the repo, feel free to proceed."
)
break break
col.prop(repo, 'username', icon='USER') col.prop(repo, 'username', icon='USER')
col.prop(repo, 'password', icon='LOCKED') col.prop(repo, 'password', icon='LOCKED')
@ -173,101 +272,4 @@ def draw_prefs_checkout(self, context):
op_row.operator('svn.checkout_cancel', text="Cancel", icon="X") op_row.operator('svn.checkout_cancel', text="Cancel", icon="X")
def draw_prefs_repos(self, context) -> None: registry = [SVN_UL_repositories, SVN_OT_repo_add, SVN_OT_repo_remove, SVN_MT_add_repo]
layout = self.layout
row = layout.row()
row.use_property_split = True
row.prop(self, 'active_repo_mode', expand=True)
auth_in_progress = False
auth_error = False
auth_proc = Processes.get('Authenticate')
if auth_proc:
auth_in_progress = auth_proc.is_running
auth_error = auth_proc.error
if self.active_repo_mode == 'CURRENT_BLEND' and not context.scene.svn.get_repo(context):
split = layout.split(factor=0.4)
split.row()
split.row().label(text="Current file is not in a repository.")
return
repo_col = layout.column()
split = repo_col.row().split()
split.row().label(text="SVN Repositories:")
row = split.row()
row.alignment = 'RIGHT'
row.prop(self, 'debug_mode')
repo_col.enabled = not auth_in_progress
list_row = repo_col.row()
col = list_row.column()
col.template_list(
"SVN_UL_repositories",
"svn_repo_list",
self,
"repositories",
self,
"active_repo_idx",
)
op_col = list_row.column()
op_col.menu('SVN_MT_add_repo', icon='ADD', text="")
op_col.operator('svn.repo_remove', icon='REMOVE', text="")
if len(self.repositories) == 0:
return
if self.active_repo_idx-1 > len(self.repositories):
return
if not self.active_repo:
return
repo_col.prop(self.active_repo, 'display_name', icon='FILE_TEXT')
repo_col.prop(self.active_repo, 'url', icon='URL')
repo_col.prop(self.active_repo, 'username', icon='USER')
repo_col.prop(self.active_repo, 'password', icon='LOCKED')
draw_process_info(context, layout.row())
if not self.active_repo.dir_exists:
draw_repo_error(layout, "Repository not found on file system.")
return
if not self.active_repo.is_valid_svn:
draw_repo_error(layout, "Directory is not an SVN repository.")
split = layout.split(factor=0.24)
split.row()
split.row().operator("svn.checkout_initiate",
text="Create New Checkout", icon='URL').create = False
return
if not self.active_repo.authenticated and not auth_in_progress and not auth_error:
draw_repo_error(
layout, "Repository not authenticated. Enter your credentials.")
return
if len(self.repositories) > 0 and self.active_repo.authenticated:
layout.separator()
layout.label(text="Files: ")
draw_repo_file_list(context, layout, self.active_repo)
if is_log_useful(context):
layout.separator()
layout.label(text="Log: ")
draw_svn_log(context, layout, file_browser=False)
def draw_repo_error(layout, message):
split = layout.split(factor=0.24)
split.row()
col = split.column()
col.alert = True
col.label(text=message, icon='ERROR')
registry = [
SVN_UL_repositories,
SVN_OT_repo_add,
SVN_OT_repo_remove,
SVN_MT_add_repo
]

View File

@ -4,7 +4,7 @@
from bpy.types import Panel from bpy.types import Panel
from ..util import get_addon_prefs from ..util import get_addon_prefs
from .ui_file_list import draw_repo_file_list, draw_process_info from .ui_file_list import draw_file_list, draw_process_info
class VIEW3D_PT_svn_credentials(Panel): class VIEW3D_PT_svn_credentials(Panel):
@ -16,12 +16,7 @@ class VIEW3D_PT_svn_credentials(Panel):
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
prefs = get_addon_prefs(context)
if prefs.active_repo_mode == 'CURRENT_BLEND':
repo = context.scene.svn.get_scene_repo(context)
else:
repo = context.scene.svn.get_repo(context) repo = context.scene.svn.get_repo(context)
return repo and not repo.authenticated return repo and not repo.authenticated
def draw(self, context): def draw(self, context):
@ -58,9 +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
repo = context.scene.svn.get_repo(context)
draw_process_info(context, layout) draw_process_info(context, layout)
draw_repo_file_list(context, layout, repo) draw_file_list(context, layout)
registry = [ registry = [