SVN: Checkout, Multi-Repo, Optimizations & Clean-up #104
@ -2,22 +2,6 @@
|
|||||||
# (c) 2021, Blender Foundation - Paul Golter
|
# (c) 2021, Blender Foundation - Paul Golter
|
||||||
# (c) 2022, Blender Foundation - Demeter Dzadik
|
# (c) 2022, Blender Foundation - Demeter Dzadik
|
||||||
|
|
||||||
bl_info = {
|
|
||||||
"name": "Blender SVN",
|
|
||||||
"author": "Demeter Dzadik, Paul Golter",
|
|
||||||
"description": "Blender Add-on to interact with Subversion.",
|
|
||||||
"blender": (3, 1, 0),
|
|
||||||
"version": (0, 2, 1),
|
|
||||||
"location": "View3D",
|
|
||||||
"warning": "",
|
|
||||||
"doc_url": "",
|
|
||||||
"tracker_url": "",
|
|
||||||
"category": "Generic",
|
|
||||||
}
|
|
||||||
|
|
||||||
from bpy.utils import register_class, unregister_class
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
props,
|
props,
|
||||||
repository,
|
repository,
|
||||||
@ -27,6 +11,21 @@ from . import (
|
|||||||
prefs,
|
prefs,
|
||||||
svn_info,
|
svn_info,
|
||||||
)
|
)
|
||||||
|
import importlib
|
||||||
|
from bpy.utils import register_class, unregister_class
|
||||||
|
bl_info = {
|
||||||
|
"name": "Blender SVN",
|
||||||
|
"author": "Demeter Dzadik, Paul Golter",
|
||||||
|
"description": "Blender Add-on to interact with Subversion.",
|
||||||
|
"blender": (3, 1, 0),
|
||||||
|
"version": (1, 0, 0),
|
||||||
|
"location": "View3D",
|
||||||
|
"warning": "",
|
||||||
|
"doc_url": "",
|
||||||
|
"tracker_url": "",
|
||||||
|
"category": "Generic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
modules = [
|
modules = [
|
||||||
props,
|
props,
|
||||||
@ -38,6 +37,7 @@ modules = [
|
|||||||
svn_info,
|
svn_info,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def register_unregister_modules(modules: list, register: bool):
|
def register_unregister_modules(modules: list, register: bool):
|
||||||
"""Recursively register or unregister modules by looking for either
|
"""Recursively register or unregister modules by looking for either
|
||||||
un/register() functions or lists named `registry` which should be a list of
|
un/register() functions or lists named `registry` which should be a list of
|
||||||
@ -54,7 +54,8 @@ def register_unregister_modules(modules: list, register: bool):
|
|||||||
register_func(c)
|
register_func(c)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
un = 'un' if not register else ''
|
un = 'un' if not register else ''
|
||||||
print(f"Warning: SVN failed to {un}register class: {c.__name__}")
|
print(
|
||||||
|
f"Warning: SVN failed to {un}register class: {c.__name__}")
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
if hasattr(m, 'modules'):
|
if hasattr(m, 'modules'):
|
||||||
@ -65,8 +66,10 @@ def register_unregister_modules(modules: list, register: bool):
|
|||||||
elif hasattr(m, 'unregister'):
|
elif hasattr(m, 'unregister'):
|
||||||
m.unregister()
|
m.unregister()
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
register_unregister_modules(modules, True)
|
register_unregister_modules(modules, True)
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
register_unregister_modules(modules, False)
|
register_unregister_modules(modules, False)
|
||||||
|
@ -2,12 +2,14 @@ from . import (
|
|||||||
simple_commands,
|
simple_commands,
|
||||||
svn_commit,
|
svn_commit,
|
||||||
svn_update,
|
svn_update,
|
||||||
ui_operators
|
ui_operators,
|
||||||
|
svn_checkout
|
||||||
)
|
)
|
||||||
|
|
||||||
modules = [
|
modules = [
|
||||||
simple_commands,
|
simple_commands,
|
||||||
svn_commit,
|
svn_commit,
|
||||||
svn_update,
|
svn_update,
|
||||||
ui_operators
|
ui_operators,
|
||||||
|
svn_checkout
|
||||||
]
|
]
|
@ -15,9 +15,13 @@ 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
|
||||||
|
|
||||||
# TODO: Add an operator to revert all local changes to the working copy.
|
|
||||||
|
|
||||||
class SVN_Operator:
|
class SVN_Operator:
|
||||||
|
@staticmethod
|
||||||
|
def update_file_list(context):
|
||||||
|
repo = context.scene.svn.get_repo(context)
|
||||||
|
repo.update_file_filter(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,
|
||||||
# we want to ignore the first update after any SVN operator.
|
# we want to ignore the first update after any SVN operator.
|
||||||
@ -31,12 +35,14 @@ class SVN_Operator_Single_File(SVN_Operator):
|
|||||||
"""Base class for SVN operators operating on a single file."""
|
"""Base class for SVN operators operating on a single file."""
|
||||||
file_rel_path: StringProperty()
|
file_rel_path: StringProperty()
|
||||||
|
|
||||||
|
# Flag to differentiate operators that require that the file exists pre-execute.
|
||||||
missing_file_allowed = False
|
missing_file_allowed = False
|
||||||
|
|
||||||
def execute(self, context: Context) -> Set[str]:
|
def execute(self, context: Context) -> Set[str]:
|
||||||
"""Most operators want to make sure that the file exists pre-execute."""
|
|
||||||
if not self.file_exists(context) and not type(self).missing_file_allowed:
|
if not self.file_exists(context) and not type(self).missing_file_allowed:
|
||||||
self.report({'ERROR'}, f'File is no longer on the file system: "{self.file_rel_path}"')
|
# If the operator requires the file to exist and it doesn't, cancel.
|
||||||
|
self.report(
|
||||||
|
{'ERROR'}, f'File is no longer on the file system: "{self.file_rel_path}"')
|
||||||
return {'CANCELLED'}
|
return {'CANCELLED'}
|
||||||
|
|
||||||
status = Processes.get('Status')
|
status = Processes.get('Status')
|
||||||
@ -47,10 +53,9 @@ class SVN_Operator_Single_File(SVN_Operator):
|
|||||||
file = self.get_file(context)
|
file = self.get_file(context)
|
||||||
if file:
|
if file:
|
||||||
Processes.start('Status')
|
Processes.start('Status')
|
||||||
# self.set_predicted_file_status(repo, file)
|
|
||||||
# file.status_prediction_type = "SKIP_ONCE"
|
|
||||||
redraw_viewport()
|
redraw_viewport()
|
||||||
|
|
||||||
|
self.update_file_list(context)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _execute(self, context: Context) -> Set[str]:
|
def _execute(self, context: Context) -> Set[str]:
|
||||||
@ -141,7 +146,8 @@ class SVN_OT_update_single(May_Modifiy_Current_Blend, Operator):
|
|||||||
|
|
||||||
def _execute(self, context: Context) -> Set[str]:
|
def _execute(self, context: Context) -> Set[str]:
|
||||||
self.will_conflict = False
|
self.will_conflict = False
|
||||||
file_entry = context.scene.svn.get_repo(context).get_file_by_svn_path(self.file_rel_path)
|
file_entry = context.scene.svn.get_repo(
|
||||||
|
context).get_file_by_svn_path(self.file_rel_path)
|
||||||
if file_entry.status not in ['normal', 'none']:
|
if file_entry.status not in ['normal', 'none']:
|
||||||
self.will_conflict = True
|
self.will_conflict = True
|
||||||
|
|
||||||
@ -175,7 +181,8 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
|
|||||||
revision: IntProperty()
|
revision: IntProperty()
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
file_entry = context.scene.svn.get_repo(context).get_file_by_svn_path(self.file_rel_path)
|
file_entry = context.scene.svn.get_repo(
|
||||||
|
context).get_file_by_svn_path(self.file_rel_path)
|
||||||
if self.file_is_current_blend(context) and file_entry.status != 'normal':
|
if self.file_is_current_blend(context) and file_entry.status != 'normal':
|
||||||
self.report({'ERROR'},
|
self.report({'ERROR'},
|
||||||
'You must first revert or commit the changes to this file.')
|
'You must first revert or commit the changes to this file.')
|
||||||
@ -183,7 +190,8 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
|
|||||||
return super().invoke(context, event)
|
return super().invoke(context, event)
|
||||||
|
|
||||||
def _execute(self, context: Context) -> Set[str]:
|
def _execute(self, context: Context) -> Set[str]:
|
||||||
file_entry = context.scene.svn.get_repo(context).get_file_by_svn_path(self.file_rel_path)
|
file_entry = context.scene.svn.get_repo(
|
||||||
|
context).get_file_by_svn_path(self.file_rel_path)
|
||||||
if file_entry.status == 'modified':
|
if file_entry.status == 'modified':
|
||||||
# If file has local modifications, let's avoid a conflict by cancelling
|
# If file has local modifications, let's avoid a conflict by cancelling
|
||||||
# and telling the user to resolve it in advance.
|
# and telling the user to resolve it in advance.
|
||||||
@ -193,7 +201,8 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
|
|||||||
|
|
||||||
self.execute_svn_command(
|
self.execute_svn_command(
|
||||||
context,
|
context,
|
||||||
["svn", "up" ,f"-r{self.revision}", f"{self.file_rel_path}", "--accept", "postpone"],
|
["svn", "up", f"-r{self.revision}",
|
||||||
|
f"{self.file_rel_path}", "--accept", "postpone"],
|
||||||
use_cred=True
|
use_cred=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -213,33 +222,6 @@ class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
|
|||||||
file_entry.repos_status = 'modified'
|
file_entry.repos_status = 'modified'
|
||||||
|
|
||||||
|
|
||||||
class SVN_OT_download_repo_revision(SVN_Operator, Operator):
|
|
||||||
bl_idname = "svn.download_repo_revision"
|
|
||||||
bl_label = "Download Repository Revision"
|
|
||||||
bl_description = "Revert the entire working copy to this revision. Can be used to see what state a project was in at a certain point in time. May take a long time to download all the files"
|
|
||||||
bl_options = {'INTERNAL'}
|
|
||||||
|
|
||||||
missing_file_allowed = True
|
|
||||||
|
|
||||||
revision: IntProperty()
|
|
||||||
|
|
||||||
def execute(self, context: Context) -> Set[str]:
|
|
||||||
# NOTE: This can take a long time, but providing a progress bar is
|
|
||||||
# fundamentally impossible because SVN itself doesn't provide the command
|
|
||||||
# line with any progress info.
|
|
||||||
# TODO: Doing it in the background may be an option, just a hassle.
|
|
||||||
output = self.execute_svn_command(
|
|
||||||
context,
|
|
||||||
["svn", "up", f"-r{self.revision}", "--accept", "postpone"],
|
|
||||||
use_cred=True
|
|
||||||
)
|
|
||||||
self.report({"INFO"}, output.split("\n")[-2])
|
|
||||||
return {"FINISHED"}
|
|
||||||
|
|
||||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
|
||||||
file_entry.status = 'normal'
|
|
||||||
|
|
||||||
|
|
||||||
class SVN_OT_restore_file(May_Modifiy_Current_Blend, Operator):
|
class SVN_OT_restore_file(May_Modifiy_Current_Blend, Operator):
|
||||||
bl_idname = "svn.restore_file"
|
bl_idname = "svn.restore_file"
|
||||||
bl_label = "Restore File"
|
bl_label = "Restore File"
|
||||||
@ -398,7 +380,8 @@ class SVN_OT_resolve_conflict(May_Modifiy_Current_Blend, Operator):
|
|||||||
def _execute(self, context: Context) -> Set[str]:
|
def _execute(self, context: Context) -> Set[str]:
|
||||||
self.execute_svn_command(
|
self.execute_svn_command(
|
||||||
context,
|
context,
|
||||||
["svn", "resolve", f"{self.file_rel_path}", "--accept", f"{self.resolve_method}"]
|
["svn", "resolve", f"{self.file_rel_path}",
|
||||||
|
"--accept", f"{self.resolve_method}"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
@ -446,7 +429,6 @@ class SVN_OT_cleanup(SVN_Operator, Operator):
|
|||||||
registry = [
|
registry = [
|
||||||
SVN_OT_update_single,
|
SVN_OT_update_single,
|
||||||
SVN_OT_download_file_revision,
|
SVN_OT_download_file_revision,
|
||||||
SVN_OT_download_repo_revision,
|
|
||||||
SVN_OT_revert_file,
|
SVN_OT_revert_file,
|
||||||
SVN_OT_restore_file,
|
SVN_OT_restore_file,
|
||||||
SVN_OT_unadd_file,
|
SVN_OT_unadd_file,
|
||||||
|
98
scripts-blender/addons/blender_svn/operators/svn_checkout.py
Normal file
98
scripts-blender/addons/blender_svn/operators/svn_checkout.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
# (c) 2022, Blender Foundation - Demeter Dzadik
|
||||||
|
|
||||||
|
from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
||||||
|
|
||||||
|
from bpy.types import Operator
|
||||||
|
from bpy.props import BoolProperty
|
||||||
|
|
||||||
|
from .simple_commands import SVN_Operator
|
||||||
|
from ..util import get_addon_prefs
|
||||||
|
from ..threaded.background_process import Processes
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class SVN_OT_checkout_initiate(Operator):
|
||||||
|
bl_idname = "svn.checkout_initiate"
|
||||||
|
bl_label = "Initiate SVN Checkout"
|
||||||
|
bl_description = "Checkout a remote SVN repository"
|
||||||
|
bl_options = {'INTERNAL'}
|
||||||
|
|
||||||
|
create: BoolProperty(
|
||||||
|
name="Create Repo Entry",
|
||||||
|
description="Whether a new repo entry should be created, or the active one used",
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
prefs = get_addon_prefs(context)
|
||||||
|
prefs.active_repo_mode = 'SELECTED_REPO'
|
||||||
|
if self.create:
|
||||||
|
prefs.repositories.add()
|
||||||
|
prefs.active_repo_idx = len(prefs.repositories)-1
|
||||||
|
|
||||||
|
prefs.checkout_mode = True
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class SVN_OT_checkout_finalize(Operator, SVN_Operator):
|
||||||
|
bl_idname = "svn.checkout_finalize"
|
||||||
|
bl_label = "Finalize SVN Checkout"
|
||||||
|
bl_description = "Checkout the specified SVN repository to the selected path"
|
||||||
|
bl_options = {'INTERNAL'}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
prefs = get_addon_prefs(context)
|
||||||
|
repo = prefs.active_repo
|
||||||
|
# `svn checkout` is an outlier in every way from other SVN commands:
|
||||||
|
# - Credentials are provided with an equal sign
|
||||||
|
# - We need live output in the console, but we don't need to store it.
|
||||||
|
# - It needs to be able to run even if the current directory isn't a valid repo.
|
||||||
|
# So, we're not going to use our `execute_subprocess` api here.
|
||||||
|
self.execute_svn_command(
|
||||||
|
context,
|
||||||
|
['svn', 'cleanup']
|
||||||
|
)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
["svn", "checkout", f"--username={repo.username}",
|
||||||
|
f"--password={repo.password}", repo.url, repo.display_name],
|
||||||
|
shell=False,
|
||||||
|
cwd=repo.directory+"/",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
repo.directory = str((Path(repo.directory) / repo.display_name))
|
||||||
|
while True:
|
||||||
|
line = p.stdout.readline().decode()
|
||||||
|
print(line.replace("\n", ""))
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
prefs = get_addon_prefs(context)
|
||||||
|
prefs.checkout_mode = False
|
||||||
|
prefs.save_repo_info_to_file()
|
||||||
|
Processes.start('Authenticate')
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class SVN_OT_checkout_cancel(Operator):
|
||||||
|
bl_idname = "svn.checkout_cancel"
|
||||||
|
bl_label = "Cancel SVN Checkout"
|
||||||
|
bl_description = "Cancel the checkout UI"
|
||||||
|
bl_options = {'INTERNAL'}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
prefs = get_addon_prefs(context)
|
||||||
|
prefs.checkout_mode = False
|
||||||
|
repo = prefs.active_repo
|
||||||
|
if not repo.url and not repo.username and not repo.password and not repo.directory:
|
||||||
|
prefs.repositories.remove(prefs.active_repo_idx)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
registry = [
|
||||||
|
SVN_OT_checkout_initiate,
|
||||||
|
SVN_OT_checkout_finalize,
|
||||||
|
SVN_OT_checkout_cancel
|
||||||
|
]
|
@ -119,7 +119,8 @@ class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
|
|||||||
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', icon='FILE_BLEND', text="Save")
|
op_row.operator('svn.save_during_commit',
|
||||||
|
icon='FILE_BLEND', text="Save")
|
||||||
row.label(text=text, icon=icon)
|
row.label(text=text, icon=icon)
|
||||||
|
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
|
@ -5,6 +5,7 @@ from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Operator, Context
|
from bpy.types import Operator, Context
|
||||||
|
from bpy.props import IntProperty
|
||||||
|
|
||||||
from .simple_commands import May_Modifiy_Current_Blend
|
from .simple_commands import May_Modifiy_Current_Blend
|
||||||
from ..threaded.background_process import Processes
|
from ..threaded.background_process import Processes
|
||||||
@ -17,6 +18,12 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
|
|||||||
bl_description = "Download all the latest updates from the remote repository"
|
bl_description = "Download all the latest updates from the remote repository"
|
||||||
bl_options = {'INTERNAL'}
|
bl_options = {'INTERNAL'}
|
||||||
|
|
||||||
|
revision: IntProperty(
|
||||||
|
name="Revision",
|
||||||
|
description="Which revision to revert the repository to. 0 means to update to the latest version instead",
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
if get_addon_prefs(context).is_busy:
|
if get_addon_prefs(context).is_busy:
|
||||||
@ -35,29 +42,56 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
|
|||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
current_blend = repo.current_blend_file
|
current_blend = repo.current_blend_file
|
||||||
|
if self.revision == 0:
|
||||||
if current_blend and current_blend.repos_status != 'none':
|
if current_blend and current_blend.repos_status != 'none':
|
||||||
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:
|
||||||
|
for f in repo.external_files:
|
||||||
|
if f.status in ['modified', 'added', 'conflicted', 'deleted', 'missing', 'unversioned']:
|
||||||
|
return context.window_manager.invoke_props_dialog(self, width=500)
|
||||||
|
|
||||||
return self.execute(context)
|
return self.execute(context)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
if self.revision != 0:
|
||||||
|
layout = self.layout
|
||||||
|
col = layout.column()
|
||||||
|
col.label(text="You have uncommitted local changes.")
|
||||||
|
col.label(
|
||||||
|
text="These won't be lost, but if you want to revert the state of the entire local repository to a ")
|
||||||
|
col.label(
|
||||||
|
text="past point in time, you would get a better result if you reverted or committed your changes first.")
|
||||||
|
col.separator()
|
||||||
|
col.label(
|
||||||
|
text="Press OK to proceed anyways. Click out of this window to cancel.")
|
||||||
|
super().draw(context)
|
||||||
|
|
||||||
def execute(self, context: Context) -> Set[str]:
|
def execute(self, context: Context) -> Set[str]:
|
||||||
self.set_predicted_file_statuses(context)
|
self.set_predicted_file_statuses(context)
|
||||||
Processes.stop('Status')
|
Processes.stop('Status')
|
||||||
if self.reload_file:
|
if self.reload_file:
|
||||||
|
command = ["svn", "up", "--accept", "postpone"]
|
||||||
|
if self.revision > 0:
|
||||||
|
command.insert(2, f"-r{self.revision}")
|
||||||
self.execute_svn_command(
|
self.execute_svn_command(
|
||||||
context,
|
context,
|
||||||
["svn", "up", "--accept", "postpone"],
|
command,
|
||||||
use_cred=True
|
use_cred=True
|
||||||
)
|
)
|
||||||
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False)
|
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False)
|
||||||
Processes.start('Log')
|
Processes.start('Log')
|
||||||
else:
|
else:
|
||||||
Processes.start('Update')
|
Processes.start('Update', revision=self.revision)
|
||||||
|
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
def set_predicted_file_statuses(self, context):
|
def set_predicted_file_statuses(self, context):
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
|
if self.revision != 0:
|
||||||
|
# File status prediction is not supported for reverting the entire
|
||||||
|
# repository. It would be complicated to implement, and not really useful.
|
||||||
|
return
|
||||||
for f in repo.external_files:
|
for f in repo.external_files:
|
||||||
status_predict_flag_bkp = f.status_prediction_type
|
status_predict_flag_bkp = f.status_prediction_type
|
||||||
f.status_prediction_type = "SVN_UP"
|
f.status_prediction_type = "SVN_UP"
|
||||||
@ -79,5 +113,5 @@ class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
|
|||||||
|
|
||||||
|
|
||||||
registry = [
|
registry = [
|
||||||
SVN_OT_update_all
|
SVN_OT_update_all,
|
||||||
]
|
]
|
||||||
|
@ -3,6 +3,7 @@ from bpy.props import BoolProperty, StringProperty
|
|||||||
from bpy.types import Operator
|
from bpy.types import Operator
|
||||||
from ..threaded.background_process import Processes
|
from ..threaded.background_process import Processes
|
||||||
|
|
||||||
|
|
||||||
class SVN_OT_custom_tooltip(Operator):
|
class SVN_OT_custom_tooltip(Operator):
|
||||||
"""Tooltip"""
|
"""Tooltip"""
|
||||||
bl_idname = "svn.custom_tooltip"
|
bl_idname = "svn.custom_tooltip"
|
||||||
|
@ -15,6 +15,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .threaded.background_process import Processes
|
from .threaded.background_process import Processes
|
||||||
|
|
||||||
|
|
||||||
class SVN_addon_preferences(AddonPreferences):
|
class SVN_addon_preferences(AddonPreferences):
|
||||||
bl_idname = __package__
|
bl_idname = __package__
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ class SVN_addon_preferences(AddonPreferences):
|
|||||||
return
|
return
|
||||||
self.idx_updating = True
|
self.idx_updating = True
|
||||||
active_repo = self.active_repo
|
active_repo = self.active_repo
|
||||||
if self.ui_mode == 'CURRENT_BLEND':
|
if self.active_repo_mode == 'CURRENT_BLEND':
|
||||||
scene_svn = context.scene.svn
|
scene_svn = context.scene.svn
|
||||||
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
|
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
|
||||||
if scene_svn_idx == -1:
|
if scene_svn_idx == -1:
|
||||||
@ -62,21 +63,28 @@ class SVN_addon_preferences(AddonPreferences):
|
|||||||
|
|
||||||
self.idx_updating = False
|
self.idx_updating = False
|
||||||
|
|
||||||
def update_ui_mode(self, context):
|
def update_active_repo_mode(self, context):
|
||||||
if self.ui_mode == 'CURRENT_BLEND':
|
if self.active_repo_mode == 'CURRENT_BLEND':
|
||||||
scene_svn = context.scene.svn
|
scene_svn = context.scene.svn
|
||||||
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
|
scene_svn_idx = self.repositories.find(scene_svn.svn_directory)
|
||||||
self.active_repo_idx = scene_svn_idx
|
self.active_repo_idx = scene_svn_idx
|
||||||
|
|
||||||
ui_mode: EnumProperty(
|
checkout_mode: BoolProperty(
|
||||||
|
name="Checkout In Progress",
|
||||||
|
description="Internal flag to indicate that the user is currently trying to create a new checkout",
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
active_repo_mode: EnumProperty(
|
||||||
name="Choose Repository",
|
name="Choose Repository",
|
||||||
description="Whether the add-on should communicate with the repository of the currently opened .blend file, or the repository selected in the list below",
|
description="Whether the add-on should communicate with the repository of the currently opened .blend file, or the repository selected in the list below",
|
||||||
items=[
|
items=[
|
||||||
('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"),
|
('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", "Communicate with the selected repository")
|
('SELECTED_REPO', "Selected Repo",
|
||||||
|
"Communicate with the selected repository")
|
||||||
],
|
],
|
||||||
default='CURRENT_BLEND',
|
default='CURRENT_BLEND',
|
||||||
update = update_ui_mode
|
update=update_active_repo_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
active_repo_idx: IntProperty(
|
active_repo_idx: IntProperty(
|
||||||
@ -91,7 +99,7 @@ class SVN_addon_preferences(AddonPreferences):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def active_repo(self) -> SVN_repository:
|
def active_repo(self) -> SVN_repository:
|
||||||
if len(self.repositories) > 0:
|
if 0 < len(self.repositories) < self.active_repo_idx-1:
|
||||||
return self.repositories[self.active_repo_idx]
|
return self.repositories[self.active_repo_idx]
|
||||||
|
|
||||||
debug_mode: BoolProperty(
|
debug_mode: BoolProperty(
|
||||||
@ -111,21 +119,25 @@ class SVN_addon_preferences(AddonPreferences):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save_repo_info_to_file(self):
|
def save_repo_info_to_file(self):
|
||||||
saved_props = {'url', 'directory', 'name', 'username', 'password', 'display_name'}
|
saved_props = {'url', 'directory', 'name',
|
||||||
|
'username', 'password', 'display_name'}
|
||||||
repo_data = {}
|
repo_data = {}
|
||||||
for repo in self['repositories']:
|
for repo in self['repositories']:
|
||||||
directory = repo.get('directory', '')
|
directory = repo.get('directory', '')
|
||||||
|
|
||||||
repo_data[directory] = {key:value for key, value in repo.to_dict().items() if key in saved_props}
|
repo_data[directory] = {
|
||||||
|
key: value for key, value in repo.to_dict().items() if key in saved_props}
|
||||||
|
|
||||||
filepath = Path(bpy.utils.user_resource('CONFIG')) / Path("blender_svn.txt")
|
filepath = Path(bpy.utils.user_resource('CONFIG')) / \
|
||||||
|
Path("blender_svn.txt")
|
||||||
with open(filepath, "w") as f:
|
with open(filepath, "w") as f:
|
||||||
json.dump(repo_data, f, indent=4)
|
json.dump(repo_data, f, indent=4)
|
||||||
|
|
||||||
def load_repo_info_from_file(self):
|
def load_repo_info_from_file(self):
|
||||||
self.loading = True
|
self.loading = True
|
||||||
try:
|
try:
|
||||||
filepath = Path(bpy.utils.user_resource('CONFIG')) / Path("blender_svn.txt")
|
filepath = Path(bpy.utils.user_resource(
|
||||||
|
'CONFIG')) / Path("blender_svn.txt")
|
||||||
if not filepath.exists():
|
if not filepath.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -148,6 +160,7 @@ class SVN_addon_preferences(AddonPreferences):
|
|||||||
|
|
||||||
draw = ui_prefs.draw_prefs
|
draw = ui_prefs.draw_prefs
|
||||||
|
|
||||||
|
|
||||||
registry = [
|
registry = [
|
||||||
SVN_addon_preferences
|
SVN_addon_preferences
|
||||||
]
|
]
|
||||||
|
@ -2,18 +2,16 @@
|
|||||||
# (c) 2021, Blender Foundation - Paul Golter
|
# (c) 2021, Blender Foundation - Paul Golter
|
||||||
# (c) 2022, Blender Foundation - Demeter Dzadik
|
# (c) 2022, Blender Foundation - Demeter Dzadik
|
||||||
|
|
||||||
|
from .util import get_addon_prefs
|
||||||
|
from bpy.props import StringProperty, PointerProperty
|
||||||
|
from bpy.types import PropertyGroup
|
||||||
|
import bpy
|
||||||
|
from pathlib import Path
|
||||||
|
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.
|
||||||
wheels.preload_dependencies()
|
wheels.preload_dependencies()
|
||||||
|
|
||||||
from typing import Optional, Dict, Any, List, Tuple, Set
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import bpy
|
|
||||||
from bpy.types import PropertyGroup
|
|
||||||
from bpy.props import StringProperty, PointerProperty
|
|
||||||
|
|
||||||
from .util import get_addon_prefs
|
|
||||||
|
|
||||||
class SVN_scene_properties(PropertyGroup):
|
class SVN_scene_properties(PropertyGroup):
|
||||||
"""Subversion properties to match this scene to a repo in the UserPrefs"""
|
"""Subversion properties to match this scene to a repo in the UserPrefs"""
|
||||||
@ -36,19 +34,18 @@ class SVN_scene_properties(PropertyGroup):
|
|||||||
"""
|
"""
|
||||||
prefs = get_addon_prefs(context)
|
prefs = get_addon_prefs(context)
|
||||||
|
|
||||||
if prefs.ui_mode == 'CURRENT_BLEND':
|
if prefs.active_repo_mode == 'CURRENT_BLEND':
|
||||||
return self.get_scene_repo(context)
|
return self.get_scene_repo(context)
|
||||||
else:
|
else:
|
||||||
return prefs.active_repo
|
return prefs.active_repo
|
||||||
|
|
||||||
def get_scene_repo(self, context) -> Optional['SVN_repository']:
|
def get_scene_repo(self, context) -> Optional['SVN_repository']:
|
||||||
scene_svn = context.scene.svn
|
if not self.svn_url or not self.svn_directory:
|
||||||
if not scene_svn.svn_url or not scene_svn.svn_directory:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
prefs = get_addon_prefs(context)
|
prefs = get_addon_prefs(context)
|
||||||
for repo in prefs.repositories:
|
for repo in prefs.repositories:
|
||||||
if (repo.url == scene_svn.svn_url) and (Path(repo.directory) == Path(scene_svn.svn_directory)):
|
if (repo.url == self.svn_url) and (Path(repo.directory) == Path(self.svn_directory)):
|
||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class SVN_file(PropertyGroup):
|
class SVN_file(PropertyGroup):
|
||||||
"""Property Group that can represent a version of a File in an SVN repository."""
|
"""Property Group that can represent a version of a File in an SVN repository."""
|
||||||
|
|
||||||
@ -52,7 +53,8 @@ class SVN_file(PropertyGroup):
|
|||||||
items=[
|
items=[
|
||||||
("NONE", "None", "File status is not predicted, but actual."),
|
("NONE", "None", "File status is not predicted, but actual."),
|
||||||
("SVN_UP", "Update", "File status is predicted by `svn up`. Status is protected until process is finished."),
|
("SVN_UP", "Update", "File status is predicted by `svn up`. Status is protected until process is finished."),
|
||||||
("SVN_COMMIT", "Commit", "File status is predicted by `svn commit`. Status is protected until process is finished."),
|
("SVN_COMMIT", "Commit",
|
||||||
|
"File status is predicted by `svn commit`. Status is protected until process is finished."),
|
||||||
("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."),
|
||||||
],
|
],
|
||||||
@ -123,6 +125,16 @@ class SVN_file(PropertyGroup):
|
|||||||
|
|
||||||
return 'QUESTION'
|
return 'QUESTION'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_default_status(self):
|
||||||
|
return self.status == 'normal' and self.repos_status == 'none' and self.status_prediction_type == 'NONE'
|
||||||
|
|
||||||
|
show_in_filelist: BoolProperty(
|
||||||
|
name="Show In File List",
|
||||||
|
description="Flag indicating whether this file should be drawn in the file list. This flag is updated for every file whenever the file search string is modified. If we did this filtering during drawing time, it is painfully slow",
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@ -155,6 +167,12 @@ class SVN_log(PropertyGroup):
|
|||||||
description="List of file entries that were affected by this revision"
|
description="List of file entries that were affected by this revision"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def changes_file(self, file: SVN_file) -> bool:
|
||||||
|
for affected_file in self.changed_files:
|
||||||
|
if affected_file.svn_path == "/"+file.svn_path:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
matches_filter: BoolProperty(
|
matches_filter: BoolProperty(
|
||||||
name="Matches Filter",
|
name="Matches Filter",
|
||||||
description="Whether the log entry matches the currently typed in search filter",
|
description="Whether the log entry matches the currently typed in search filter",
|
||||||
@ -179,6 +197,12 @@ class SVN_log(PropertyGroup):
|
|||||||
date = self.revision_date_simple
|
date = self.revision_date_simple
|
||||||
return " ".join([rev, auth, files, msg, date]).lower()
|
return " ".join([rev, auth, files, msg, date]).lower()
|
||||||
|
|
||||||
|
affects_active_file: BoolProperty(
|
||||||
|
name="Affects Active File",
|
||||||
|
description="Flag set whenever the active file index updates. Used to accelerate drawing performance by moving filtering logic from the drawing code to update callbacks and flags",
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SVN_repository(PropertyGroup):
|
class SVN_repository(PropertyGroup):
|
||||||
### Basic SVN Info. ###
|
### Basic SVN Info. ###
|
||||||
@ -204,6 +228,7 @@ class SVN_repository(PropertyGroup):
|
|||||||
self.name = self.directory
|
self.name = self.directory
|
||||||
|
|
||||||
root_dir, base_url = get_svn_info(self.directory)
|
root_dir, base_url = get_svn_info(self.directory)
|
||||||
|
if root_dir and base_url:
|
||||||
self.initialize(root_dir, base_url)
|
self.initialize(root_dir, base_url)
|
||||||
|
|
||||||
directory: StringProperty(
|
directory: StringProperty(
|
||||||
@ -213,13 +238,30 @@ class SVN_repository(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",
|
||||||
update=update_directory
|
update=update_directory
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_valid(self):
|
def dir_exists(self):
|
||||||
dir_path = Path(self.directory)
|
dir_path = Path(self.directory)
|
||||||
return dir_path.exists() and dir_path.is_dir()
|
return dir_path.exists() and dir_path.is_dir()
|
||||||
|
|
||||||
def initialize(self, directory: str, url: str, display_name=""):
|
@property
|
||||||
|
def is_valid_svn(self):
|
||||||
|
dir_path = Path(self.directory)
|
||||||
|
root_dir, base_url = get_svn_info(self.directory)
|
||||||
|
return (
|
||||||
|
dir_path.exists() and
|
||||||
|
dir_path.is_dir() and
|
||||||
|
root_dir and base_url and
|
||||||
|
root_dir == self.directory and
|
||||||
|
base_url == self.url
|
||||||
|
)
|
||||||
|
|
||||||
|
def initialize(self, directory: str, url: str, display_name="", username="", password=""):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
if username:
|
||||||
|
self.username = username
|
||||||
|
if password:
|
||||||
|
self.password = password
|
||||||
if self.directory != directory:
|
if self.directory != directory:
|
||||||
# Don't set this if it's already set, to avoid infinite recursion
|
# Don't set this if it's already set, to avoid infinite recursion
|
||||||
# via the update callback.
|
# via the update callback.
|
||||||
@ -231,9 +273,6 @@ class SVN_repository(PropertyGroup):
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def exists(self) -> bool:
|
|
||||||
return Path(self.directory).exists()
|
|
||||||
|
|
||||||
### Credentials. ###
|
### Credentials. ###
|
||||||
def update_cred(self, context):
|
def update_cred(self, context):
|
||||||
if not (self.username and self.password):
|
if not (self.username and self.password):
|
||||||
@ -248,7 +287,10 @@ class SVN_repository(PropertyGroup):
|
|||||||
|
|
||||||
def authenticate(self, context):
|
def authenticate(self, context):
|
||||||
self.auth_failed = False
|
self.auth_failed = False
|
||||||
|
if self.is_valid_svn and self.is_cred_entered:
|
||||||
Processes.start('Authenticate')
|
Processes.start('Authenticate')
|
||||||
|
# Trigger the file list filtering.
|
||||||
|
self.file_search_filter = self.file_search_filter
|
||||||
|
|
||||||
username: StringProperty(
|
username: StringProperty(
|
||||||
name="Username",
|
name="Username",
|
||||||
@ -347,7 +389,6 @@ class SVN_repository(PropertyGroup):
|
|||||||
current = file.revision
|
current = file.revision
|
||||||
return latest > current
|
return latest > current
|
||||||
|
|
||||||
|
|
||||||
### SVN File List. ###
|
### SVN File List. ###
|
||||||
external_files: CollectionProperty(type=SVN_file)
|
external_files: CollectionProperty(type=SVN_file)
|
||||||
|
|
||||||
@ -421,6 +462,13 @@ class SVN_repository(PropertyGroup):
|
|||||||
relative_path=self.active_file.name)
|
relative_path=self.active_file.name)
|
||||||
Processes.start('Activate File')
|
Processes.start('Activate File')
|
||||||
|
|
||||||
|
# Filter out log entries that did not affect the selected file.
|
||||||
|
self.log.foreach_set(
|
||||||
|
'affects_active_file',
|
||||||
|
[log_entry.changes_file(self.active_file)
|
||||||
|
for log_entry in self.log]
|
||||||
|
)
|
||||||
|
|
||||||
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",
|
||||||
@ -465,18 +513,11 @@ 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 ###
|
||||||
# These are normally stored on the UIList, but then they cannot be accessed
|
# Filtering properties are normally stored on the UIList,
|
||||||
# from anywhere else, since template_list() does not return the UIList instance.
|
# but then they cannot be accessed from anywhere else,
|
||||||
|
# since template_list() does not return the UIList instance.
|
||||||
# We need to be able to access them outside of drawing code, to be able to
|
# We need to be able to access them outside of drawing code, to be able to
|
||||||
# know which entries are visible and ensure that a filtered out entry can never
|
# ensure that a filtered out entry can never be the active one.
|
||||||
# be the active one.
|
|
||||||
|
|
||||||
def get_visible_indicies(self, context) -> List[int]:
|
|
||||||
flt_flags, _flt_neworder = bpy.types.SVN_UL_file_list.cls_filter_items(
|
|
||||||
context, self, 'external_files')
|
|
||||||
|
|
||||||
visible_indicies = [i for i, flag in enumerate(flt_flags) if flag != 0]
|
|
||||||
return visible_indicies
|
|
||||||
|
|
||||||
def force_good_active_index(self, context) -> bool:
|
def force_good_active_index(self, context) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -484,14 +525,36 @@ class SVN_repository(PropertyGroup):
|
|||||||
If the active element is being filtered out, set the active element to
|
If the active element is being filtered out, set the active element to
|
||||||
something that is visible.
|
something that is visible.
|
||||||
"""
|
"""
|
||||||
visible_indicies = self.get_visible_indicies(context)
|
if len(self.external_files) == 0:
|
||||||
if len(visible_indicies) == 0:
|
return
|
||||||
self.external_files_active_index = 0
|
if not self.active_file.show_in_filelist:
|
||||||
elif self.external_files_active_index not in visible_indicies:
|
for i, file in enumerate(self.external_files):
|
||||||
self.external_files_active_index = visible_indicies[0]
|
if file.show_in_filelist:
|
||||||
|
self.external_files_active_index = i
|
||||||
|
return
|
||||||
|
|
||||||
def update_file_filter(self, context):
|
def update_file_filter(self, context):
|
||||||
"""Should run when any of the SVN file list search filters are changed."""
|
"""Should run when any of the SVN file list search filters are changed."""
|
||||||
|
|
||||||
|
UI_LIST = bpy.types.UI_UL_list
|
||||||
|
if self.file_search_filter:
|
||||||
|
filter_list = UI_LIST.filter_items_by_name(
|
||||||
|
self.file_search_filter,
|
||||||
|
1,
|
||||||
|
self.external_files,
|
||||||
|
"name",
|
||||||
|
reverse=False
|
||||||
|
)
|
||||||
|
filter_list = [bool(val) for val in filter_list]
|
||||||
|
self.external_files.foreach_set('show_in_filelist', filter_list)
|
||||||
|
else:
|
||||||
|
for file in self.external_files:
|
||||||
|
if file == self.current_blend_file:
|
||||||
|
file.show_in_filelist = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
file.show_in_filelist = not file.has_default_status
|
||||||
|
|
||||||
self.force_good_active_index(context)
|
self.force_good_active_index(context)
|
||||||
|
|
||||||
file_search_filter: StringProperty(
|
file_search_filter: StringProperty(
|
||||||
|
@ -4,15 +4,20 @@ import subprocess
|
|||||||
|
|
||||||
from .threaded.execute_subprocess import execute_command
|
from .threaded.execute_subprocess import execute_command
|
||||||
|
|
||||||
|
|
||||||
def get_svn_info(path: Path or str) -> Tuple[str, str]:
|
def get_svn_info(path: Path or str) -> Tuple[str, str]:
|
||||||
"""Use the `svn info` command to get the root dir, the URL, and the relative URL."""
|
"""Use the `svn info` command to get the root dir, the URL, and the relative URL."""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return "", ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dirpath_str = str(Path(path).as_posix())
|
dirpath_str = str(path.as_posix())
|
||||||
svn_info = execute_command(dirpath_str, ["svn", "info"])
|
svn_info = execute_command(dirpath_str, ["svn", "info"])
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
error_msg = e.stderr.decode()
|
error_msg = e.stderr.decode()
|
||||||
if "is not a working copy" in error_msg:
|
if "is not a working copy" in error_msg:
|
||||||
return None, None, None
|
return None, None
|
||||||
elif "E200009" in error_msg:
|
elif "E200009" in error_msg:
|
||||||
# If we're in a folder that wasn't yet added to the repo,
|
# If we're in a folder that wasn't yet added to the repo,
|
||||||
# try again one folder higher.
|
# try again one folder higher.
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
# (c) 2022, Blender Foundation - Demeter Dzadik
|
# (c) 2022, Blender Foundation - Demeter Dzadik
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import threading, subprocess
|
import threading
|
||||||
|
import subprocess
|
||||||
import random
|
import random
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@ -67,6 +68,9 @@ class BackgroundProcess:
|
|||||||
Should save data into self.output and self.error.
|
Should save data into self.output and self.error.
|
||||||
Reading Blender data from this function is safe, but writing isn't!
|
Reading Blender data from this function is safe, but writing isn't!
|
||||||
"""
|
"""
|
||||||
|
repo = context.scene.svn.get_repo(context)
|
||||||
|
if not repo.is_valid_svn:
|
||||||
|
self.stop()
|
||||||
try:
|
try:
|
||||||
self.acquire_output(context, prefs)
|
self.acquire_output(context, prefs)
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
@ -78,6 +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
|
||||||
|
|
||||||
def process_output(self, context, prefs):
|
def process_output(self, context, prefs):
|
||||||
"""
|
"""
|
||||||
@ -133,7 +138,6 @@ class BackgroundProcess:
|
|||||||
return self.tick_delay
|
return self.tick_delay
|
||||||
elif self.error:
|
elif self.error:
|
||||||
self.debug_print("Shutdown: There was an error.")
|
self.debug_print("Shutdown: There was an error.")
|
||||||
self.is_running = False
|
|
||||||
return
|
return
|
||||||
elif self.output:
|
elif self.output:
|
||||||
self.debug_print("Processing output")
|
self.debug_print("Processing output")
|
||||||
@ -203,6 +207,8 @@ def get_recursive_subclasses(typ) -> List[type]:
|
|||||||
|
|
||||||
|
|
||||||
processes = {}
|
processes = {}
|
||||||
|
|
||||||
|
|
||||||
class ProcessManager:
|
class ProcessManager:
|
||||||
@property
|
@property
|
||||||
def processes(self):
|
def processes(self):
|
||||||
@ -224,7 +230,6 @@ class ProcessManager:
|
|||||||
if proc_name in self.processes:
|
if proc_name in self.processes:
|
||||||
return self.processes[proc_name].is_running
|
return self.processes[proc_name].is_running
|
||||||
|
|
||||||
|
|
||||||
def get(self, proc_name: str):
|
def get(self, proc_name: str):
|
||||||
return self.processes.get(proc_name)
|
return self.processes.get(proc_name)
|
||||||
|
|
||||||
@ -256,6 +261,7 @@ class ProcessManager:
|
|||||||
process.stop()
|
process.stop()
|
||||||
del self.processes[proc_name]
|
del self.processes[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.
|
||||||
Processes = ProcessManager()
|
Processes = ProcessManager()
|
@ -10,6 +10,7 @@ from .background_process import Processes, BackgroundProcess
|
|||||||
from .execute_subprocess import execute_svn_command
|
from .execute_subprocess import execute_svn_command
|
||||||
from ..util import get_addon_prefs
|
from ..util import get_addon_prefs
|
||||||
|
|
||||||
|
|
||||||
class BGP_SVN_Commit(BackgroundProcess):
|
class BGP_SVN_Commit(BackgroundProcess):
|
||||||
name = "Commit"
|
name = "Commit"
|
||||||
needs_authentication = True
|
needs_authentication = True
|
||||||
@ -33,7 +34,8 @@ class BGP_SVN_Commit(BackgroundProcess):
|
|||||||
|
|
||||||
Processes.kill('Status')
|
Processes.kill('Status')
|
||||||
sanitized_commit_msg = self.commit_msg.replace('"', "'")
|
sanitized_commit_msg = self.commit_msg.replace('"', "'")
|
||||||
command = ["svn", "commit", "-m", f"{sanitized_commit_msg}"] + self.file_list
|
command = ["svn", "commit", "-m",
|
||||||
|
f"{sanitized_commit_msg}"] + self.file_list
|
||||||
self.output = execute_svn_command(
|
self.output = execute_svn_command(
|
||||||
context,
|
context,
|
||||||
command,
|
command,
|
||||||
@ -67,4 +69,3 @@ class BGP_SVN_Commit(BackgroundProcess):
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
|
|
@ -4,6 +4,7 @@
|
|||||||
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!"
|
||||||
@ -34,7 +35,7 @@ def execute_svn_command(context, command: List[str], *, ignore_errors=False, pri
|
|||||||
command.append("--non-interactive")
|
command.append("--non-interactive")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if repo.is_valid:
|
if repo.is_valid_svn:
|
||||||
return execute_command(repo.directory, command)
|
return execute_command(repo.directory, command)
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
if ignore_errors:
|
if ignore_errors:
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from .background_process import BackgroundProcess
|
from .background_process import BackgroundProcess
|
||||||
|
|
||||||
|
|
||||||
class BGP_SVN_Activate_File(BackgroundProcess):
|
class BGP_SVN_Activate_File(BackgroundProcess):
|
||||||
"""This crazy hacky method of activating the file with some delay is necessary
|
"""This crazy hacky method of activating the file with some delay is necessary
|
||||||
because Blender won't let us select the file immediately when changing the
|
because Blender won't let us select the file immediately when changing the
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from .background_process import BackgroundProcess, Processes
|
from .background_process import BackgroundProcess, Processes
|
||||||
from ..util import redraw_viewport
|
from ..util import redraw_viewport
|
||||||
|
|
||||||
|
|
||||||
class BGP_SVN_Redraw_Viewport(BackgroundProcess):
|
class BGP_SVN_Redraw_Viewport(BackgroundProcess):
|
||||||
name = "Redraw Viewport"
|
name = "Redraw Viewport"
|
||||||
repeat_delay = 1
|
repeat_delay = 1
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@ -57,7 +58,8 @@ def reload_svn_log(self, context):
|
|||||||
log_entry.revision_author = r_author
|
log_entry.revision_author = r_author
|
||||||
|
|
||||||
log_entry.revision_date = r_date
|
log_entry.revision_date = r_date
|
||||||
log_entry.revision_date_simple = svn_date_simple(r_date).split(" ")[0][5:]
|
log_entry.revision_date_simple = svn_date_simple(r_date).split(" ")[
|
||||||
|
0][5:]
|
||||||
|
|
||||||
# File change set is on line 3 until the commit message begins...
|
# File change set is on line 3 until the commit message begins...
|
||||||
file_change_lines = chunk[2:-(r_msg_length+1)]
|
file_change_lines = chunk[2:-(r_msg_length+1)]
|
||||||
@ -74,7 +76,8 @@ def reload_svn_log(self, context):
|
|||||||
log_file_entry = log_entry.changed_files.add()
|
log_file_entry = log_entry.changed_files.add()
|
||||||
log_file_entry.name = file_path.name
|
log_file_entry.name = file_path.name
|
||||||
log_file_entry.svn_path = str(file_path.as_posix())
|
log_file_entry.svn_path = str(file_path.as_posix())
|
||||||
log_file_entry.absolute_path = str(repo.svn_to_absolute_path(file_path).as_posix())
|
log_file_entry.absolute_path = str(
|
||||||
|
repo.svn_to_absolute_path(file_path).as_posix())
|
||||||
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]
|
||||||
|
|
||||||
@ -146,11 +149,12 @@ class BGP_SVN_Log(BackgroundProcess):
|
|||||||
try:
|
try:
|
||||||
self.output = execute_svn_command(
|
self.output = execute_svn_command(
|
||||||
context,
|
context,
|
||||||
["svn", "log", "--verbose", f"-r{latest_log_rev+1}:HEAD", "--limit", "10"],
|
["svn", "log", "--verbose",
|
||||||
|
f"-r{latest_log_rev+1}:HEAD", "--limit", "10"],
|
||||||
print_errors=False,
|
print_errors=False,
|
||||||
use_cred=True
|
use_cred=True
|
||||||
)
|
)
|
||||||
self.debug_print("Output: \n" + self.output)
|
self.debug_print("Output: \n" + str(self.output))
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
error_msg = error.stderr.decode()
|
error_msg = error.stderr.decode()
|
||||||
if "No such revision" in error_msg:
|
if "No such revision" in error_msg:
|
||||||
@ -170,7 +174,7 @@ class BGP_SVN_Log(BackgroundProcess):
|
|||||||
rev_no = repo.log[-1].revision_number
|
rev_no = repo.log[-1].revision_number
|
||||||
return f"Updating log. Current: r{rev_no}..."
|
return f"Updating log. Current: r{rev_no}..."
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
def svn_date_to_datetime(datetime_str: str) -> datetime:
|
def svn_date_to_datetime(datetime_str: str) -> datetime:
|
||||||
"""Convert a string from SVN's datetime format to a datetime object."""
|
"""Convert a string from SVN's datetime format to a datetime object."""
|
||||||
date, time, _timezone, _day, _n_day, _mo, _y = datetime_str.split(" ")
|
date, time, _timezone, _day, _n_day, _mo, _y = datetime_str.split(" ")
|
||||||
|
@ -1,25 +1,22 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
from ..svn_info import get_svn_info
|
||||||
|
from ..util import get_addon_prefs
|
||||||
|
from .. import constants
|
||||||
|
from .execute_subprocess import execute_svn_command
|
||||||
|
from .background_process import BackgroundProcess, Processes
|
||||||
|
from bpy.types import Operator
|
||||||
|
from bpy.props import StringProperty
|
||||||
|
import bpy
|
||||||
|
import xmltodict
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
||||||
from .. import wheels
|
from .. import wheels
|
||||||
# This will load the xmltodict wheel file.
|
# This will load the xmltodict wheel file.
|
||||||
wheels.preload_dependencies()
|
wheels.preload_dependencies()
|
||||||
|
|
||||||
from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
|
||||||
from pathlib import Path
|
|
||||||
import time
|
|
||||||
import xmltodict
|
|
||||||
|
|
||||||
import bpy
|
|
||||||
from bpy.props import StringProperty
|
|
||||||
from bpy.types import Operator
|
|
||||||
|
|
||||||
from .background_process import BackgroundProcess, Processes
|
|
||||||
from .execute_subprocess import execute_svn_command
|
|
||||||
from .. import constants
|
|
||||||
from ..util import get_addon_prefs
|
|
||||||
from ..svn_info import get_svn_info
|
|
||||||
|
|
||||||
|
|
||||||
class SVN_OT_explain_status(Operator):
|
class SVN_OT_explain_status(Operator):
|
||||||
bl_idname = "svn.explain_status"
|
bl_idname = "svn.explain_status"
|
||||||
@ -77,7 +74,7 @@ def init_svn_of_current_file(_scene=None):
|
|||||||
repo.authenticated = False
|
repo.authenticated = False
|
||||||
repo.auth_failed = False
|
repo.auth_failed = False
|
||||||
|
|
||||||
if prefs.ui_mode == 'CURRENT_BLEND':
|
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
|
||||||
@ -90,6 +87,10 @@ def init_svn_of_current_file(_scene=None):
|
|||||||
if not repo:
|
if not repo:
|
||||||
repo = prefs.init_repo(context, scene_svn.svn_directory)
|
repo = prefs.init_repo(context, scene_svn.svn_directory)
|
||||||
|
|
||||||
|
for i, other_repo in enumerate(prefs.repositories):
|
||||||
|
if other_repo == repo:
|
||||||
|
prefs.active_repo_idx = i
|
||||||
|
|
||||||
else:
|
else:
|
||||||
repo = prefs.active_repo
|
repo = prefs.active_repo
|
||||||
if not repo:
|
if not repo:
|
||||||
@ -125,6 +126,7 @@ def set_scene_svn_info(context) -> bool:
|
|||||||
############## AUTOMATICALLY KEEPING FILE STATUSES UP TO DATE ##################
|
############## AUTOMATICALLY KEEPING FILE STATUSES UP TO DATE ##################
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
|
|
||||||
class BGP_SVN_Status(BackgroundProcess):
|
class BGP_SVN_Status(BackgroundProcess):
|
||||||
name = "Status"
|
name = "Status"
|
||||||
needs_authentication = True
|
needs_authentication = True
|
||||||
@ -168,7 +170,7 @@ class BGP_SVN_Authenticate(BGP_SVN_Status):
|
|||||||
|
|
||||||
def acquire_output(self, context, prefs):
|
def acquire_output(self, context, prefs):
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
if not repo or not repo.is_cred_entered or repo.authenticated:
|
if not repo or not repo.is_valid_svn or not repo.is_cred_entered or repo.authenticated:
|
||||||
return
|
return
|
||||||
|
|
||||||
super().acquire_output(context, prefs)
|
super().acquire_output(context, prefs)
|
||||||
@ -224,7 +226,8 @@ def update_file_list(context, file_statuses: Dict[str, Tuple[str, str, int]]):
|
|||||||
entry_existed = False
|
entry_existed = False
|
||||||
file_entry = repo.external_files.add()
|
file_entry = repo.external_files.add()
|
||||||
file_entry.svn_path = svn_path_str
|
file_entry.svn_path = svn_path_str
|
||||||
file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path).as_posix())
|
file_entry.absolute_path = str(
|
||||||
|
repo.svn_to_absolute_path(svn_path).as_posix())
|
||||||
|
|
||||||
file_entry['name'] = svn_path.name
|
file_entry['name'] = svn_path.name
|
||||||
if not file_entry.exists:
|
if not file_entry.exists:
|
||||||
@ -257,7 +260,8 @@ def update_file_list(context, file_statuses: Dict[str, Tuple[str, str, int]]):
|
|||||||
# File entry status has changed between local and repo.
|
# File entry status has changed between local and repo.
|
||||||
file_strings = []
|
file_strings = []
|
||||||
for svn_path, repos_status in new_files_on_repo:
|
for svn_path, repos_status in new_files_on_repo:
|
||||||
status_char = constants.SVN_STATUS_NAME_TO_CHAR.get(repos_status, " ")
|
status_char = constants.SVN_STATUS_NAME_TO_CHAR.get(
|
||||||
|
repos_status, " ")
|
||||||
file_strings.append(f"{status_char} {svn_path}")
|
file_strings.append(f"{status_char} {svn_path}")
|
||||||
print(
|
print(
|
||||||
"SVN: Detected file changes on remote:\n",
|
"SVN: Detected file changes on remote:\n",
|
||||||
@ -273,6 +277,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.force_good_active_index(context)
|
repo.force_good_active_index(context)
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,11 +14,19 @@ class BGP_SVN_Update(BackgroundProcess):
|
|||||||
repeat_delay = 0
|
repeat_delay = 0
|
||||||
debug = False
|
debug = False
|
||||||
|
|
||||||
|
def __init__(self, revision=0):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.revision = revision
|
||||||
|
|
||||||
def acquire_output(self, context, prefs):
|
def acquire_output(self, context, prefs):
|
||||||
Processes.kill('Status')
|
Processes.kill('Status')
|
||||||
|
command = ["svn", "up", "--accept", "postpone"]
|
||||||
|
if self.revision > 0:
|
||||||
|
command.insert(2, f"-r{self.revision}")
|
||||||
self.output = execute_svn_command(
|
self.output = execute_svn_command(
|
||||||
context,
|
context,
|
||||||
["svn", "up", "--accept", "postpone"],
|
command,
|
||||||
use_cred=True
|
use_cred=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -36,7 +44,6 @@ class BGP_SVN_Update(BackgroundProcess):
|
|||||||
Processes.start('Log')
|
Processes.start('Log')
|
||||||
Processes.start('Status')
|
Processes.start('Status')
|
||||||
|
|
||||||
|
|
||||||
def get_ui_message(self, context) -> str:
|
def get_ui_message(self, context) -> str:
|
||||||
"""Return a string that should be drawn in the UI for user feedback,
|
"""Return a string that should be drawn in the UI for user feedback,
|
||||||
depending on the state of the process."""
|
depending on the state of the process."""
|
||||||
|
@ -6,6 +6,7 @@ from bpy.types import Context, UIList, Operator
|
|||||||
from bpy.props import StringProperty
|
from bpy.props import StringProperty
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class SVN_OT_open_blend_file(Operator):
|
class SVN_OT_open_blend_file(Operator):
|
||||||
# This is needed because drawing a button for wm.open_mainfile in the UI
|
# 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,
|
# directly simply does not work; Blender just opens a full-screen filebrowser,
|
||||||
@ -58,7 +59,7 @@ def svn_log_list_context_menu(self: UIList, context: Context) -> None:
|
|||||||
|
|
||||||
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_filebrowser if is_filebrowser else repo.active_log
|
||||||
layout.operator("svn.download_repo_revision",
|
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()
|
||||||
|
|
||||||
@ -67,8 +68,10 @@ def register():
|
|||||||
bpy.types.UI_MT_list_item_context_menu.append(svn_file_list_context_menu)
|
bpy.types.UI_MT_list_item_context_menu.append(svn_file_list_context_menu)
|
||||||
bpy.types.UI_MT_list_item_context_menu.append(svn_log_list_context_menu)
|
bpy.types.UI_MT_list_item_context_menu.append(svn_log_list_context_menu)
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
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]
|
registry = [SVN_OT_open_blend_file]
|
@ -28,7 +28,6 @@ class SVN_UL_file_list(UIList):
|
|||||||
if self.layout_type != 'DEFAULT':
|
if self.layout_type != 'DEFAULT':
|
||||||
raise NotImplemented
|
raise NotImplemented
|
||||||
|
|
||||||
repo = data
|
|
||||||
file_entry = item
|
file_entry = item
|
||||||
prefs = get_addon_prefs(context)
|
prefs = get_addon_prefs(context)
|
||||||
|
|
||||||
@ -121,9 +120,10 @@ class SVN_UL_file_list(UIList):
|
|||||||
properties to the addon preferences) we can find a visible entry
|
properties to the addon preferences) we can find a visible entry
|
||||||
from other UI code, allowing us to avoid situations where the active
|
from other UI code, allowing us to avoid situations where the active
|
||||||
element becomes hidden."""
|
element becomes hidden."""
|
||||||
flt_flags = []
|
|
||||||
flt_neworder = []
|
flt_neworder = []
|
||||||
list_items = getattr(data, propname)
|
list_items = getattr(data, propname)
|
||||||
|
flt_flags = [file.show_in_filelist *
|
||||||
|
cls.UILST_FLT_ITEM for file in list_items]
|
||||||
|
|
||||||
helper_funcs = bpy.types.UI_UL_list
|
helper_funcs = bpy.types.UI_UL_list
|
||||||
|
|
||||||
@ -134,25 +134,6 @@ class SVN_UL_file_list(UIList):
|
|||||||
if not repo:
|
if not repo:
|
||||||
return flt_flags, flt_neworder
|
return flt_flags, flt_neworder
|
||||||
|
|
||||||
def has_default_status(file):
|
|
||||||
return file.status == 'normal' and file.repos_status == 'none' and file.status_prediction_type == 'NONE'
|
|
||||||
|
|
||||||
if repo.file_search_filter:
|
|
||||||
flt_flags = helper_funcs.filter_items_by_name(repo.file_search_filter, cls.UILST_FLT_ITEM, list_items, "name",
|
|
||||||
reverse=False)
|
|
||||||
else:
|
|
||||||
# Start with all files visible.
|
|
||||||
flt_flags = [cls.UILST_FLT_ITEM] * len(list_items)
|
|
||||||
|
|
||||||
for i, item in enumerate(list_items):
|
|
||||||
if item == repo.current_blend_file:
|
|
||||||
# ALWAYS display the current .blend file.
|
|
||||||
continue
|
|
||||||
|
|
||||||
if has_default_status(item):
|
|
||||||
# Filter out files that have default statuses.
|
|
||||||
flt_flags[i] = 0
|
|
||||||
|
|
||||||
return flt_flags, flt_neworder
|
return flt_flags, flt_neworder
|
||||||
|
|
||||||
def filter_items(self, context, data, propname):
|
def filter_items(self, context, data, propname):
|
||||||
@ -167,7 +148,8 @@ 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="")
|
row.prop(context.scene.svn.get_repo(context),
|
||||||
|
'file_search_filter', text="")
|
||||||
|
|
||||||
|
|
||||||
def draw_process_info(context, layout):
|
def draw_process_info(context, layout):
|
||||||
@ -197,7 +179,8 @@ def draw_process_info(context, layout):
|
|||||||
if not any_error and process_message:
|
if not any_error and process_message:
|
||||||
col.label(text=process_message)
|
col.label(text=process_message)
|
||||||
if prefs.debug_mode:
|
if prefs.debug_mode:
|
||||||
col.label(text="Processes: " + ", ".join([p.name for p in Processes.running_processes]))
|
col.label(text="Processes: " +
|
||||||
|
", ".join([p.name for p in Processes.running_processes]))
|
||||||
|
|
||||||
|
|
||||||
def draw_repo_file_list(context, layout, repo):
|
def draw_repo_file_list(context, layout, repo):
|
||||||
@ -243,7 +226,7 @@ def draw_repo_file_list(context, layout, repo):
|
|||||||
|
|
||||||
col.separator()
|
col.separator()
|
||||||
col.operator("svn.commit", icon='EXPORT', text="")
|
col.operator("svn.commit", icon='EXPORT', text="")
|
||||||
col.operator("svn.update_all", icon='IMPORT', text="")
|
col.operator("svn.update_all", icon='IMPORT', text="").revision = 0
|
||||||
|
|
||||||
col.separator()
|
col.separator()
|
||||||
col.operator("svn.cleanup", icon='BRUSH_DATA', text="")
|
col.operator("svn.cleanup", icon='BRUSH_DATA', text="")
|
||||||
|
@ -67,4 +67,3 @@ registry = [
|
|||||||
FILEBROWSER_PT_SVN_files,
|
FILEBROWSER_PT_SVN_files,
|
||||||
FILEBROWSER_PT_SVN_log
|
FILEBROWSER_PT_SVN_log
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -60,24 +60,17 @@ class SVN_UL_log(UIList):
|
|||||||
key=lambda i: log_entries[i].revision_number)
|
key=lambda i: log_entries[i].revision_number)
|
||||||
flt_neworder.reverse()
|
flt_neworder.reverse()
|
||||||
|
|
||||||
is_filebrowser = context.space_data.type == 'FILE_BROWSER'
|
|
||||||
active_file = svn.get_filebrowser_active_file(
|
|
||||||
context) if is_filebrowser else svn.active_file
|
|
||||||
|
|
||||||
if not self.show_all_logs:
|
if not self.show_all_logs:
|
||||||
# Filter out log entries that did not affect the selected file.
|
flt_flags = [
|
||||||
for idx, log_entry in enumerate(log_entries):
|
log_entry.affects_active_file * self.bitflag_filter_item
|
||||||
for affected_file in log_entry.changed_files:
|
for log_entry in log_entries
|
||||||
if affected_file.svn_path == "/"+active_file.svn_path:
|
]
|
||||||
# If the active file is one of the files affected by this log
|
|
||||||
# entry, break the for loop and skip the else block.
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
flt_flags[idx] = 0
|
|
||||||
|
|
||||||
|
if self.filter_name:
|
||||||
# Filtering: Allow comma-separated keywords.
|
# Filtering: Allow comma-separated keywords.
|
||||||
# ALL keywords must be found somewhere in the log entry for it to show up.
|
# ALL keywords must be found somewhere in the log entry for it to show up.
|
||||||
filter_words = [word.strip().lower() for word in self.filter_name.split(",")]
|
filter_words = [word.strip().lower()
|
||||||
|
for word in self.filter_name.split(",")]
|
||||||
for idx, log_entry in enumerate(log_entries):
|
for idx, log_entry in enumerate(log_entries):
|
||||||
for filter_word in filter_words:
|
for filter_word in filter_words:
|
||||||
if filter_word not in log_entry.text_to_search:
|
if filter_word not in log_entry.text_to_search:
|
||||||
@ -97,15 +90,16 @@ class SVN_UL_log(UIList):
|
|||||||
|
|
||||||
def is_log_useful(context):
|
def is_log_useful(context):
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
if len(repo.log) == 0:
|
if len(repo.log) == 0 or len(repo.external_files) == 0:
|
||||||
return False
|
|
||||||
any_visible = repo.get_visible_indicies(context)
|
|
||||||
if not any_visible:
|
|
||||||
return False
|
return False
|
||||||
active_file = repo.active_file
|
active_file = repo.active_file
|
||||||
if active_file.status in ['unversioned', 'added']:
|
if active_file.status in ['unversioned', 'added']:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
any_visible = any([file.show_in_filelist for file in repo.external_files])
|
||||||
|
if not any_visible:
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
def draw_outdated_file_warning(self, context):
|
def draw_outdated_file_warning(self, context):
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
if not repo:
|
if not repo:
|
||||||
@ -31,6 +32,7 @@ def draw_outdated_file_warning(self, context):
|
|||||||
'svn.custom_tooltip', text="SVN: This .blend file is outdated.", icon='ERROR')
|
'svn.custom_tooltip', text="SVN: This .blend file is outdated.", icon='ERROR')
|
||||||
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"
|
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.VIEW3D_HT_header.prepend(draw_outdated_file_warning)
|
||||||
|
|
||||||
|
@ -3,24 +3,31 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bpy.types import UIList, Operator
|
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_repo_file_list, draw_process_info
|
||||||
from ..threaded.background_process import Processes
|
from ..threaded.background_process import Processes
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
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.is_valid:
|
if not repo.dir_exists:
|
||||||
row.alert = True
|
row.alert = True
|
||||||
row.prop(repo, 'directory', text="")
|
row.prop(repo, 'directory', text="")
|
||||||
|
|
||||||
|
|
||||||
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'}
|
||||||
@ -40,7 +47,8 @@ class SVN_OT_repo_add(Operator, ImportHelper):
|
|||||||
try:
|
try:
|
||||||
repo = prefs.init_repo(context, path)
|
repo = prefs.init_repo(context, path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.report({'ERROR'}, "Failed to initialize repository. Ensure you have SVN installed, and that the selected directory is the root of a repository.")
|
self.report(
|
||||||
|
{'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:
|
||||||
@ -54,6 +62,7 @@ class SVN_OT_repo_add(Operator, ImportHelper):
|
|||||||
prefs.save_repo_info_to_file()
|
prefs.save_repo_info_to_file()
|
||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
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'}
|
||||||
@ -77,12 +86,99 @@ class SVN_OT_repo_remove(Operator):
|
|||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
def draw_prefs(self, context) -> None:
|
class SVN_MT_add_repo(Menu):
|
||||||
|
bl_idname = "SVN_MT_add_repo"
|
||||||
|
bl_label = "Add Repo"
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
layout.operator(
|
||||||
|
"svn.repo_add", text="Browse Existing Checkout", icon='FILE_FOLDER')
|
||||||
|
layout.operator("svn.checkout_initiate",
|
||||||
|
text="Create New Checkout", icon='URL').create = True
|
||||||
|
|
||||||
|
|
||||||
|
def draw_prefs(self, context):
|
||||||
|
if self.checkout_mode:
|
||||||
|
draw_prefs_checkout(self, context)
|
||||||
|
else:
|
||||||
|
draw_prefs_repos(self, context)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_prefs_checkout(self, context):
|
||||||
|
def get_terminal_howto():
|
||||||
|
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_mac = msg_linux
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
return msg_windows
|
||||||
|
elif system == "Linux":
|
||||||
|
return msg_linux
|
||||||
|
elif system == "Darwin":
|
||||||
|
return msg_mac
|
||||||
|
|
||||||
|
layout = self.layout
|
||||||
|
col = layout.column()
|
||||||
|
col.alert = True
|
||||||
|
|
||||||
|
col.label(text="IMPORTANT! ", icon='ERROR')
|
||||||
|
col.label(text="Make sure you have Blender's terminal open!")
|
||||||
|
col.label(text=get_terminal_howto())
|
||||||
|
col.separator()
|
||||||
|
col.label(
|
||||||
|
text="Downloading a repository can take a long time, and the UI will be locked.")
|
||||||
|
col.label(
|
||||||
|
text="Without a terminal, you won't be able to track the progress of the checkout.")
|
||||||
|
col.separator()
|
||||||
|
|
||||||
|
col = layout.column()
|
||||||
|
col.label(
|
||||||
|
text="To interrupt the checkout, you can press Ctrl+C in the terminal.", icon='INFO')
|
||||||
|
col.label(
|
||||||
|
text="You can resume it by re-running this operation, or with the SVN Update button.", icon='INFO')
|
||||||
|
col.separator()
|
||||||
|
|
||||||
|
prefs = get_addon_prefs(context)
|
||||||
|
repo = prefs.repositories[-1]
|
||||||
|
col.prop(repo, 'directory')
|
||||||
|
for other_repo in prefs.repositories:
|
||||||
|
if other_repo == repo:
|
||||||
|
continue
|
||||||
|
if other_repo.directory == repo.directory:
|
||||||
|
row = col.row()
|
||||||
|
row.alert = True
|
||||||
|
row.label(
|
||||||
|
text="A repository at this filepath is already specified.", icon='ERROR')
|
||||||
|
break
|
||||||
|
|
||||||
|
col.prop(repo, 'display_name', text="Folder Name", icon='NEWFOLDER')
|
||||||
|
col.prop(repo, 'url', icon='URL')
|
||||||
|
for other_repo in prefs.repositories:
|
||||||
|
if other_repo == repo:
|
||||||
|
continue
|
||||||
|
if other_repo.url == repo.url:
|
||||||
|
sub = col.column()
|
||||||
|
sub.alert = True
|
||||||
|
sub.label(text="A repository with this URL is already specified.")
|
||||||
|
sub.label(
|
||||||
|
text="If you're sure you want to checkout another copy of the repo, feel free to proceed.")
|
||||||
|
break
|
||||||
|
col.prop(repo, 'username', icon='USER')
|
||||||
|
col.prop(repo, 'password', icon='LOCKED')
|
||||||
|
|
||||||
|
op_row = layout.row()
|
||||||
|
op_row.operator('svn.checkout_finalize', text="Checkout", icon='CHECKMARK')
|
||||||
|
op_row.operator('svn.checkout_cancel', text="Cancel", icon="X")
|
||||||
|
|
||||||
|
|
||||||
|
def draw_prefs_repos(self, context) -> None:
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
|
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
row.use_property_split = True
|
row.use_property_split = True
|
||||||
row.prop(self, 'ui_mode', expand=True)
|
row.prop(self, 'active_repo_mode', expand=True)
|
||||||
|
|
||||||
auth_in_progress = False
|
auth_in_progress = False
|
||||||
auth_error = False
|
auth_error = False
|
||||||
@ -91,7 +187,7 @@ def draw_prefs(self, context) -> None:
|
|||||||
auth_in_progress = auth_proc.is_running
|
auth_in_progress = auth_proc.is_running
|
||||||
auth_error = auth_proc.error
|
auth_error = auth_proc.error
|
||||||
|
|
||||||
if self.ui_mode == 'CURRENT_BLEND' and not context.scene.svn.get_repo(context):
|
if self.active_repo_mode == 'CURRENT_BLEND' and not context.scene.svn.get_repo(context):
|
||||||
split = layout.split(factor=0.4)
|
split = layout.split(factor=0.4)
|
||||||
split.row()
|
split.row()
|
||||||
split.row().label(text="Current file is not in a repository.")
|
split.row().label(text="Current file is not in a repository.")
|
||||||
@ -107,8 +203,6 @@ def draw_prefs(self, context) -> None:
|
|||||||
repo_col.enabled = not auth_in_progress
|
repo_col.enabled = not auth_in_progress
|
||||||
|
|
||||||
list_row = repo_col.row()
|
list_row = repo_col.row()
|
||||||
if self.ui_mode == 'CURRENT_BLEND':
|
|
||||||
list_row.enabled = False
|
|
||||||
col = list_row.column()
|
col = list_row.column()
|
||||||
col.template_list(
|
col.template_list(
|
||||||
"SVN_UL_repositories",
|
"SVN_UL_repositories",
|
||||||
@ -120,29 +214,36 @@ def draw_prefs(self, context) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
op_col = list_row.column()
|
op_col = list_row.column()
|
||||||
op_col.operator('svn.repo_add', icon='ADD', text="")
|
op_col.menu('SVN_MT_add_repo', icon='ADD', text="")
|
||||||
op_col.operator('svn.repo_remove', icon='REMOVE', text="")
|
op_col.operator('svn.repo_remove', icon='REMOVE', text="")
|
||||||
|
|
||||||
if len(self.repositories) == 0:
|
if len(self.repositories) == 0:
|
||||||
return
|
return
|
||||||
if self.active_repo_idx-1 > len(self.repositories):
|
if self.active_repo_idx-1 > len(self.repositories):
|
||||||
return
|
return
|
||||||
active_repo = self.repositories[self.active_repo_idx]
|
if not self.active_repo:
|
||||||
if not active_repo:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
repo_col.prop(active_repo, 'display_name', icon='FILE_TEXT')
|
repo_col.prop(self.active_repo, 'display_name', icon='FILE_TEXT')
|
||||||
repo_col.prop(active_repo, 'url', icon='URL')
|
repo_col.prop(self.active_repo, 'url', icon='URL')
|
||||||
repo_col.prop(active_repo, 'username', icon='USER')
|
repo_col.prop(self.active_repo, 'username', icon='USER')
|
||||||
repo_col.prop(active_repo, 'password', icon='LOCKED')
|
repo_col.prop(self.active_repo, 'password', icon='LOCKED')
|
||||||
|
|
||||||
draw_process_info(context, layout.row())
|
draw_process_info(context, layout.row())
|
||||||
|
|
||||||
if not self.active_repo.authenticated and not auth_in_progress and not auth_error:
|
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 = layout.split(factor=0.24)
|
||||||
split.row()
|
split.row()
|
||||||
col = split.column()
|
split.row().operator("svn.checkout_initiate",
|
||||||
col.label(text="Repository not authenticated. Enter your credentials.")
|
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
|
return
|
||||||
|
|
||||||
if len(self.repositories) > 0 and self.active_repo.authenticated:
|
if len(self.repositories) > 0 and self.active_repo.authenticated:
|
||||||
@ -156,8 +257,17 @@ def draw_prefs(self, context) -> None:
|
|||||||
draw_svn_log(context, layout, file_browser=False)
|
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 = [
|
registry = [
|
||||||
SVN_UL_repositories,
|
SVN_UL_repositories,
|
||||||
SVN_OT_repo_add,
|
SVN_OT_repo_add,
|
||||||
SVN_OT_repo_remove
|
SVN_OT_repo_remove,
|
||||||
|
SVN_MT_add_repo
|
||||||
]
|
]
|
@ -17,7 +17,7 @@ class VIEW3D_PT_svn_credentials(Panel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
prefs = get_addon_prefs(context)
|
prefs = get_addon_prefs(context)
|
||||||
if prefs.ui_mode == 'CURRENT_BLEND':
|
if prefs.active_repo_mode == 'CURRENT_BLEND':
|
||||||
repo = context.scene.svn.get_scene_repo(context)
|
repo = context.scene.svn.get_scene_repo(context)
|
||||||
else:
|
else:
|
||||||
repo = context.scene.svn.get_repo(context)
|
repo = context.scene.svn.get_repo(context)
|
||||||
@ -67,4 +67,3 @@ registry = [
|
|||||||
VIEW3D_PT_svn_credentials,
|
VIEW3D_PT_svn_credentials,
|
||||||
VIEW3D_PT_svn_files,
|
VIEW3D_PT_svn_files,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -8,12 +8,15 @@ import bpy
|
|||||||
|
|
||||||
package_name = __package__
|
package_name = __package__
|
||||||
|
|
||||||
|
|
||||||
def get_addon_prefs(context):
|
def get_addon_prefs(context):
|
||||||
return context.preferences.addons[__package__].preferences
|
return context.preferences.addons[__package__].preferences
|
||||||
|
|
||||||
|
|
||||||
def dots():
|
def dots():
|
||||||
return "." * int((time() % 10) + 3)
|
return "." * int((time() % 10) + 3)
|
||||||
|
|
||||||
|
|
||||||
def redraw_viewport(context=None) -> None:
|
def redraw_viewport(context=None) -> None:
|
||||||
"""This causes the sidebar UI to refresh without having to mouse-hover it."""
|
"""This causes the sidebar UI to refresh without having to mouse-hover it."""
|
||||||
context = bpy.context
|
context = bpy.context
|
||||||
|
Loading…
Reference in New Issue
Block a user