2025-12-01
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from . import (
|
||||
simple_commands,
|
||||
svn_commit,
|
||||
svn_update,
|
||||
ui_operators,
|
||||
svn_checkout
|
||||
)
|
||||
|
||||
modules = [
|
||||
simple_commands,
|
||||
svn_commit,
|
||||
svn_update,
|
||||
ui_operators,
|
||||
svn_checkout
|
||||
]
|
||||
@@ -0,0 +1,503 @@
|
||||
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty, IntProperty, EnumProperty, BoolProperty
|
||||
from bpy.types import Context, Operator
|
||||
|
||||
from send2trash import send2trash
|
||||
|
||||
from ..threaded.execute_subprocess import execute_svn_command
|
||||
from ..threaded.background_process import Processes
|
||||
from ..util import get_addon_prefs, redraw_viewport
|
||||
|
||||
|
||||
class SVN_Operator:
|
||||
@staticmethod
|
||||
def update_file_list(context):
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
repo.refresh_ui_lists(context)
|
||||
|
||||
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,
|
||||
# we want to ignore the first update after any SVN operator.
|
||||
# Otherwise it can result in a predicted state being overwritten by an outdated state.
|
||||
# For example, the Commit operator sets a file to "Normal" state, then the old svn status
|
||||
# arrives and sets it back to "Modified" state, which it isn't anymore.
|
||||
return execute_svn_command(context, command, use_cred=use_cred)
|
||||
|
||||
|
||||
class SVN_Operator_Single_File(SVN_Operator):
|
||||
"""Base class for SVN operators operating on a single file."""
|
||||
|
||||
file_rel_path: StringProperty()
|
||||
|
||||
# Flag to differentiate operators that require that the file exists pre-execute.
|
||||
missing_file_allowed = False
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
if not self.file_exists(context) and not type(self).missing_file_allowed:
|
||||
# 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'}
|
||||
|
||||
status = Processes.get('Status')
|
||||
if status:
|
||||
Processes.kill('Status')
|
||||
ret = self._execute(context)
|
||||
|
||||
file = self.get_file(context)
|
||||
Processes.start('Status')
|
||||
redraw_viewport()
|
||||
|
||||
self.update_file_list(context)
|
||||
return ret
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_file_full_path(self, context) -> Path:
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
return Path.joinpath(Path(repo.directory), Path(self.file_rel_path))
|
||||
|
||||
def get_file(self, context) -> "SVN_file":
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
return repo.external_files.get(self.file_rel_path)
|
||||
|
||||
def file_exists(self, context) -> bool:
|
||||
exists = self.get_file_full_path(context).exists()
|
||||
if not exists and not type(self).missing_file_allowed:
|
||||
self.report({'INFO'}, "File was not found, cancelling.")
|
||||
return exists
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
return
|
||||
|
||||
|
||||
class Popup_Operator:
|
||||
popup_width = 400
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self, width=type(self).popup_width
|
||||
)
|
||||
|
||||
|
||||
class Warning_Operator(Popup_Operator):
|
||||
def draw(self, context):
|
||||
layout = self.layout.column(align=True)
|
||||
|
||||
warning = self.get_warning_text(context)
|
||||
for line in warning.split("\n"):
|
||||
row = layout.row()
|
||||
row.alert = True
|
||||
row.label(text=line)
|
||||
|
||||
def get_warning_text(self, context):
|
||||
raise NotImplemented
|
||||
|
||||
|
||||
class May_Modifiy_Current_Blend(SVN_Operator_Single_File, Warning_Operator):
|
||||
def file_is_current_blend(self, context) -> bool:
|
||||
current_blend = context.scene.svn.get_repo(context).current_blend_file
|
||||
return current_blend and current_blend.svn_path == self.file_rel_path
|
||||
|
||||
set_outdated_flag = True
|
||||
|
||||
reload_file: BoolProperty(
|
||||
name="Reload File (Keep UI)",
|
||||
description="Reload the file after the operation is completed. The UI layout will be preserved",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.reload_file = False
|
||||
if self.file_is_current_blend(context) or self.get_warning_text(context):
|
||||
return context.window_manager.invoke_props_dialog(self, width=500)
|
||||
|
||||
return self.execute(context)
|
||||
|
||||
def get_warning_text(self, context):
|
||||
if self.file_is_current_blend(context):
|
||||
return "This will modify the currently opened .blend file."
|
||||
return ""
|
||||
|
||||
def draw(self, context):
|
||||
super().draw(context)
|
||||
if self.file_is_current_blend(context):
|
||||
self.layout.prop(self, 'reload_file')
|
||||
|
||||
def execute(self, context):
|
||||
super().execute(context)
|
||||
if self.reload_file:
|
||||
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False)
|
||||
elif self.file_is_current_blend(context) and self.set_outdated_flag:
|
||||
context.scene.svn.file_is_outdated = True
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SVN_OT_update_single(May_Modifiy_Current_Blend, Operator):
|
||||
bl_idname = "svn.update_single"
|
||||
bl_label = "Update File"
|
||||
bl_description = (
|
||||
"Download the latest available version of this file from the remote repository"
|
||||
)
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
missing_file_allowed = True
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
self.will_conflict = False
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
file_entry = repo.external_files.get(self.file_rel_path)
|
||||
if file_entry.status not in ['normal', 'none']:
|
||||
self.will_conflict = True
|
||||
|
||||
self.execute_svn_command(
|
||||
context,
|
||||
["svn", "up", f"{self.file_rel_path}", "--accept", "postpone"],
|
||||
use_cred=True,
|
||||
)
|
||||
|
||||
self.report({'INFO'}, f'Updated "{self.file_rel_path}" to the latest version.')
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
if self.will_conflict:
|
||||
file_entry.status = 'conflicted'
|
||||
else:
|
||||
file_entry.status = 'normal'
|
||||
file_entry.repos_status = 'none'
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SVN_OT_download_file_revision(May_Modifiy_Current_Blend, Operator):
|
||||
bl_idname = "svn.download_file_revision"
|
||||
bl_label = "Download Revision"
|
||||
bl_description = "Download this revision of this file"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
missing_file_allowed = True
|
||||
|
||||
revision: IntProperty(default=0)
|
||||
|
||||
def invoke(self, context, event):
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
file_entry = repo.external_files.get(self.file_rel_path)
|
||||
if self.file_is_current_blend(context) and file_entry.status != 'normal':
|
||||
self.report(
|
||||
{'ERROR'}, 'You must first revert or commit the changes to this file.'
|
||||
)
|
||||
return {'CANCELLED'}
|
||||
return super().invoke(context, event)
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
file_entry = repo.external_files.get(self.file_rel_path)
|
||||
if file_entry.status == 'modified':
|
||||
# If file has local modifications, let's avoid a conflict by cancelling
|
||||
# and telling the user to resolve it in advance.
|
||||
self.report(
|
||||
{'ERROR'},
|
||||
"Cancelled: You have local modifications to this file. You must revert or commit it first!",
|
||||
)
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.svn_download_file_revision(context, self.file_rel_path, self.revision)
|
||||
|
||||
self.report(
|
||||
{'INFO'}, f"Checked out revision {self.revision} of {self.file_rel_path}"
|
||||
)
|
||||
|
||||
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"):
|
||||
file_entry['revision'] = self.revision
|
||||
latest_rev = repo.get_latest_revision_of_file(self.file_rel_path)
|
||||
if latest_rev == self.revision:
|
||||
file_entry.status = 'normal'
|
||||
file_entry.repos_status = 'none'
|
||||
else:
|
||||
file_entry.status = 'normal'
|
||||
file_entry.repos_status = 'modified'
|
||||
|
||||
|
||||
class SVN_OT_restore_file(May_Modifiy_Current_Blend, Operator):
|
||||
bl_idname = "svn.restore_file"
|
||||
bl_label = "Restore File"
|
||||
bl_description = "Restore this deleted file to its previously checked out revision"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
missing_file_allowed = True
|
||||
|
||||
def svn_revert(self, context, svn_file_path):
|
||||
self.execute_svn_command(context, ["svn", "revert", f"{svn_file_path}"])
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
self.svn_revert(context, self.file_rel_path)
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
file_entry.status = 'normal'
|
||||
|
||||
|
||||
class SVN_OT_revert_file(SVN_OT_restore_file):
|
||||
bl_idname = "svn.revert_file"
|
||||
bl_label = "Revert File"
|
||||
bl_description = "PERMANENTLY DISCARD local changes to this file and return it to the state of the last local revision. Cannot be undone"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
missing_file_allowed = False
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
bl_idname = "svn.add_file"
|
||||
bl_label = "Add File"
|
||||
bl_description = (
|
||||
"Mark this file for addition to the remote repository. It can then be committed"
|
||||
)
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
result = self.execute_svn_command(
|
||||
context, ["svn", "add", f"{self.file_rel_path}"]
|
||||
)
|
||||
|
||||
if result:
|
||||
f = self.get_file(context)
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
file_entry.status = 'added'
|
||||
|
||||
|
||||
class SVN_OT_unadd_file(SVN_Operator_Single_File, Operator):
|
||||
bl_idname = "svn.unadd_file"
|
||||
bl_label = "Un-Add File"
|
||||
bl_description = "Un-mark this file as being added to the remote repository. It will not be committed"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
self.execute_svn_command(
|
||||
context, ["svn", "rm", "--keep-local", f"{self.file_rel_path}"]
|
||||
)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
file_entry.status = 'unversioned'
|
||||
|
||||
|
||||
class SVN_OT_trash_file(SVN_Operator_Single_File, Warning_Operator, Operator):
|
||||
bl_idname = "svn.trash_file"
|
||||
bl_label = "Trash File"
|
||||
bl_description = "Move this file to the recycle bin"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
file_rel_path: StringProperty()
|
||||
missing_file_allowed = False
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
send2trash([self.get_file_full_path(context)])
|
||||
|
||||
f = self.get_file(context)
|
||||
# Since this operator is only available for Unversioned files,
|
||||
# we want to remove the file entry when removing the file.
|
||||
context.scene.svn.get_repo(context).remove_file_entry(f)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SVN_OT_remove_file(SVN_Operator_Single_File, Warning_Operator, Operator):
|
||||
bl_idname = "svn.remove_file"
|
||||
bl_label = "Remove File"
|
||||
bl_description = "Mark this file for removal from the remote repository"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
missing_file_allowed = True
|
||||
|
||||
def get_warning_text(self, context):
|
||||
return (
|
||||
"This file will be deleted for everyone:\n "
|
||||
+ self.file_rel_path
|
||||
+ "\nAre you sure?"
|
||||
)
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
self.execute_svn_command(context, ["svn", "remove", f"{self.file_rel_path}"])
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
file_entry.status = 'deleted'
|
||||
|
||||
|
||||
class SVN_OT_resolve_conflict(May_Modifiy_Current_Blend, Operator):
|
||||
bl_idname = "svn.resolve_conflict"
|
||||
bl_label = "Resolve Conflict"
|
||||
bl_description = "Resolve a conflict, by discarding either local or remote changes"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
set_outdated_flag = False
|
||||
|
||||
resolve_method: EnumProperty(
|
||||
name="Resolve Method",
|
||||
description="Method to use to resolve the conflict",
|
||||
items=[
|
||||
(
|
||||
'mine-full',
|
||||
'Keep Mine',
|
||||
'Overwrite the new changes downloaded from the remote, and keep the local changes instead',
|
||||
),
|
||||
(
|
||||
'theirs-full',
|
||||
'Keep Theirs',
|
||||
'Overwrite the local changes with those downloaded from the remote',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
wm = context.window_manager
|
||||
return wm.invoke_props_dialog(self, width=500)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
col = layout.column(align=True)
|
||||
col.alert = True
|
||||
col.label(text="Choose which version of the file to keep.")
|
||||
col.row().prop(self, 'resolve_method', expand=True)
|
||||
col.separator()
|
||||
if self.resolve_method == 'mine-full':
|
||||
col.label(text="Local changes will be kept.")
|
||||
col.label(
|
||||
text="When committing, the changes someone else made will be overwritten."
|
||||
)
|
||||
else:
|
||||
col.label(text="Local changes will be permanently lost.")
|
||||
super().draw(context)
|
||||
|
||||
def _execute(self, context: Context) -> Set[str]:
|
||||
self.execute_svn_command(
|
||||
context,
|
||||
[
|
||||
"svn",
|
||||
"resolve",
|
||||
f"{self.file_rel_path}",
|
||||
"--accept",
|
||||
f"{self.resolve_method}",
|
||||
],
|
||||
)
|
||||
if self.file_is_current_blend(context):
|
||||
# If user wants to keep their changes to the current file,
|
||||
# remove the outdated file warning UI.
|
||||
context.scene.svn.file_is_outdated = self.resolve_method != 'mine-full'
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_status(self, repo, file_entry: "SVN_file"):
|
||||
if self.resolve_method == 'mine-full':
|
||||
file_entry.status = 'modified'
|
||||
else:
|
||||
file_entry.status = 'normal'
|
||||
|
||||
|
||||
class SVN_OT_cleanup(SVN_Operator, Operator):
|
||||
bl_idname = "svn.cleanup"
|
||||
bl_label = "SVN Cleanup"
|
||||
bl_description = "Resolve issues that can arise from previous SVN processes having been interrupted"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
# Don't allow attempting to cleanup while Update/Commit is running.
|
||||
return not get_addon_prefs(context).is_busy
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
|
||||
repo.external_files.clear()
|
||||
self.execute_svn_command(context, ["svn", "cleanup"])
|
||||
repo.reload_svn_log(context)
|
||||
|
||||
Processes.kill('Commit')
|
||||
Processes.kill('Update')
|
||||
Processes.kill('Authenticate')
|
||||
Processes.kill('Activate File')
|
||||
|
||||
Processes.restart('Status')
|
||||
Processes.restart('Log')
|
||||
Processes.restart('Redraw Viewport')
|
||||
|
||||
self.report({'INFO'}, "SVN Cleanup complete.")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
registry = [
|
||||
SVN_OT_update_single,
|
||||
SVN_OT_revert_and_update,
|
||||
SVN_OT_revert_file,
|
||||
SVN_OT_restore_file,
|
||||
SVN_OT_download_file_revision,
|
||||
SVN_OT_add_file,
|
||||
SVN_OT_unadd_file,
|
||||
SVN_OT_trash_file,
|
||||
SVN_OT_remove_file,
|
||||
SVN_OT_resolve_conflict,
|
||||
SVN_OT_cleanup,
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
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)
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import List, Dict, Union, Any, Set, Optional, Tuple
|
||||
|
||||
import bpy
|
||||
from bpy.types import PropertyGroup, Operator, Context
|
||||
from bpy.props import StringProperty
|
||||
|
||||
from ..threaded.background_process import Processes
|
||||
from .simple_commands import SVN_Operator, Popup_Operator
|
||||
from ..util import get_addon_prefs
|
||||
|
||||
# Store a reference to the running operator in global namespace when it runs,
|
||||
# so that its sub-operators can mess
|
||||
active_commit_operator = None
|
||||
|
||||
|
||||
class SVN_commit_line(PropertyGroup):
|
||||
"""Property Group representing a single line of a commit message.
|
||||
Only needed for UI/UX purpose, so we can store the commit message
|
||||
even if the user changes their mind about wanting to commit."""
|
||||
|
||||
def update_line(self, context):
|
||||
line_entries = context.scene.svn.get_repo(context).commit_lines
|
||||
for i, line_entry in enumerate(line_entries):
|
||||
if line_entry == self and i >= len(line_entries)-2:
|
||||
# The last line was just modified
|
||||
if self.line:
|
||||
# Content was added to the last line - add another line.
|
||||
line_entries.add()
|
||||
|
||||
line: StringProperty(update=update_line)
|
||||
|
||||
|
||||
class SVN_OT_commit(SVN_Operator, Popup_Operator, Operator):
|
||||
bl_idname = "svn.commit"
|
||||
bl_label = "SVN Commit"
|
||||
bl_description = "Commit a selection of files to the remote repository"
|
||||
bl_options = {'INTERNAL'}
|
||||
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
|
||||
# for us to be able to focus the input box automatically when the window pops up
|
||||
# (see bl_property above)
|
||||
def update_first_line(self, context):
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
repo.commit_lines[0].line = self.first_line
|
||||
|
||||
first_line: StringProperty(
|
||||
name="First Line",
|
||||
description="First line of the commit message",
|
||||
update=update_first_line
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_committable_files(context) -> List["SVN_file"]:
|
||||
"""Return the list of file entries whose status allows committing"""
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
if not repo:
|
||||
return
|
||||
|
||||
svn_file_list = repo.external_files
|
||||
committable_statuses = ['modified', 'added', 'deleted']
|
||||
files_to_commit = [
|
||||
f for f in svn_file_list if f.status in committable_statuses]
|
||||
return files_to_commit
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if get_addon_prefs(context).is_busy:
|
||||
# Don't allow attempting to Update/Commit while either is still running.
|
||||
return False
|
||||
|
||||
return cls.get_committable_files(context)
|
||||
|
||||
def invoke(self, context, event):
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
if repo.commit_message == "":
|
||||
repo.commit_message = ""
|
||||
|
||||
global active_commit_operator
|
||||
active_commit_operator = self
|
||||
|
||||
self.first_line = repo.commit_lines[0].line
|
||||
self.is_file_really_dirty = bpy.data.is_dirty
|
||||
|
||||
# This flag is needed as a workaround because bpy.data.is_dirty gets set to True
|
||||
# when we change the operator's checkboxes or
|
||||
self.is_file_dirty_on_invoke = bpy.data.is_dirty
|
||||
|
||||
for f in repo.external_files:
|
||||
f.include_in_commit = False
|
||||
for f in self.get_committable_files(context):
|
||||
if not f.will_conflict:
|
||||
f.include_in_commit = True
|
||||
|
||||
return super().invoke(context, event)
|
||||
|
||||
def draw(self, context):
|
||||
"""Draws the boolean toggle list with a list of strings for the button texts."""
|
||||
layout = self.layout
|
||||
files = self.get_committable_files(context)
|
||||
layout.label(
|
||||
text="These files will be pushed to the remote repository:")
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
row = layout.row()
|
||||
row.label(text="Filename")
|
||||
row.label(text="Status")
|
||||
for file in files:
|
||||
row = layout.row()
|
||||
split = row.split()
|
||||
checkbox_ui = split.row()
|
||||
status_ui = split.row()
|
||||
checkbox_ui.prop(file, "include_in_commit", text=file.file_name)
|
||||
text = file.status_name
|
||||
icon = file.status_icon
|
||||
if file.will_conflict:
|
||||
# We don't want to conflict-resolve during a commit, it's
|
||||
# confusing. User should resolve this as a separate step.
|
||||
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!"
|
||||
icon = 'ERROR'
|
||||
op_row = split.row()
|
||||
op_row.alignment = 'LEFT'
|
||||
op_row.operator('svn.save_during_commit',
|
||||
icon='FILE_BLEND', text="Save")
|
||||
status_ui.label(text=text, icon=icon)
|
||||
|
||||
row = layout.row()
|
||||
row.label(text="Commit message:")
|
||||
# Draw input box for first line, which is special because we want it to
|
||||
# get focused automatically for smooth UX. (see `bl_property` above)
|
||||
row = layout.row()
|
||||
row.prop(self, 'first_line', text="")
|
||||
row.operator(SVN_OT_commit_msg_clear.bl_idname, text="", icon='TRASH')
|
||||
for i in range(1, len(repo.commit_lines)):
|
||||
# Draw input boxes until the last one that has text, plus two, minimum three.
|
||||
# Why two after the last line? Because then you can use Tab to go to the next line.
|
||||
# Why at least 3 lines? Because then you can write a one-liner without
|
||||
# the OK button jumping away.
|
||||
layout.prop(
|
||||
repo.commit_lines[i], 'line', index=i, text="")
|
||||
continue
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
committable_files = self.get_committable_files(context)
|
||||
files_to_commit = [f for f in committable_files if f.include_in_commit]
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
|
||||
if not files_to_commit:
|
||||
self.report({'ERROR'},
|
||||
"No files were selected, nothing to commit.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
if len(repo.commit_message) < 2:
|
||||
self.report({'ERROR'},
|
||||
"Please describe your changes in the commit message.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
filepaths = [f.svn_path for f in files_to_commit]
|
||||
|
||||
self.set_predicted_file_statuses(files_to_commit)
|
||||
Processes.stop('Status')
|
||||
Processes.start('Commit',
|
||||
commit_msg=repo.commit_message,
|
||||
file_list=filepaths
|
||||
)
|
||||
|
||||
report = f"{(len(files_to_commit))} files"
|
||||
if len(files_to_commit) == 1:
|
||||
report = files_to_commit[0].svn_path
|
||||
self.report({'INFO'},
|
||||
f"Started committing {report}. See console for when it's finished.")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def set_predicted_file_statuses(self, file_entries):
|
||||
for f in file_entries:
|
||||
if f.status != 'deleted':
|
||||
if f.repos_status == 'none':
|
||||
# We modified the file, and it was not modified on the repo,
|
||||
# predict the status to be "normal".
|
||||
f.status = 'normal'
|
||||
else:
|
||||
# If we modified the file, but it was modified on the repo:
|
||||
f.status = 'conflicted'
|
||||
# TODO: What happens if we DID delete the file, AND it was modified on the repo?
|
||||
# Should probably also predict a conflict.
|
||||
f.status_prediction_type = "SVN_COMMIT"
|
||||
|
||||
|
||||
class SVN_OT_commit_save_file(Operator):
|
||||
bl_idname = "svn.save_during_commit"
|
||||
bl_label = "Save During SVN Commit"
|
||||
bl_description = "Save During SVN Commit"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def execute(self, context):
|
||||
global active_commit_operator
|
||||
active_commit_operator.is_file_really_dirty = False
|
||||
bpy.ops.wm.save_mainfile()
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SVN_OT_commit_msg_clear(Operator):
|
||||
bl_idname = "svn.clear_commit_message"
|
||||
bl_label = "Clear SVN Commit Message"
|
||||
bl_description = "Clear the commit message"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def execute(self, context):
|
||||
context.scene.svn.get_repo(context).commit_message = ""
|
||||
global active_commit_operator
|
||||
active_commit_operator.first_line = ""
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
registry = [
|
||||
SVN_OT_commit,
|
||||
SVN_OT_commit_save_file,
|
||||
SVN_OT_commit_msg_clear,
|
||||
SVN_commit_line
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Set
|
||||
|
||||
import bpy
|
||||
from bpy.types import Operator, Context
|
||||
from bpy.props import IntProperty
|
||||
|
||||
from .simple_commands import May_Modifiy_Current_Blend
|
||||
from ..threaded.background_process import Processes
|
||||
from ..util import get_addon_prefs
|
||||
|
||||
|
||||
class SVN_OT_update_all(May_Modifiy_Current_Blend, Operator):
|
||||
bl_idname = "svn.update_all"
|
||||
bl_label = "SVN Update All"
|
||||
bl_description = "Download all the latest updates from the remote repository"
|
||||
bl_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
|
||||
def poll(cls, context):
|
||||
if get_addon_prefs(context).is_busy:
|
||||
# Don't allow attempting to Update/Commit while either is still running.
|
||||
return False
|
||||
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
if not repo:
|
||||
return False
|
||||
for f in repo.external_files:
|
||||
if f.repos_status != 'none':
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
repo = context.scene.svn.get_repo(context)
|
||||
current_blend = repo.current_blend_file
|
||||
if self.revision == 0:
|
||||
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
|
||||
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']:
|
||||
# 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 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]:
|
||||
Processes.stop('Status')
|
||||
if self.reload_file:
|
||||
current_file = context.scene.svn.get_repo(context).current_blend_file
|
||||
command = ["svn", "up", current_file.svn_path, "--accept", "postpone"]
|
||||
if self.revision > 0:
|
||||
command.insert(2, f"-r{self.revision}")
|
||||
self.execute_svn_command(
|
||||
context,
|
||||
command,
|
||||
use_cred=True
|
||||
)
|
||||
bpy.ops.wm.open_mainfile(filepath=bpy.data.filepath, load_ui=False)
|
||||
Processes.start('Log')
|
||||
|
||||
Processes.start('Update', revision=self.revision)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
registry = [
|
||||
SVN_OT_update_all,
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from bpy.props import BoolProperty, StringProperty
|
||||
from bpy.types import Operator
|
||||
from ..threaded.background_process import Processes
|
||||
|
||||
|
||||
class SVN_OT_custom_tooltip(Operator):
|
||||
"""Tooltip"""
|
||||
bl_idname = "svn.custom_tooltip"
|
||||
bl_label = ""
|
||||
bl_description = " "
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
tooltip: StringProperty(
|
||||
name="Tooltip",
|
||||
description="Tooltip that is displayed when mouse hovering this operator"
|
||||
)
|
||||
copy_on_click: BoolProperty(
|
||||
name="Copy on Click",
|
||||
description="If True, the tooltip will be copied to the clipboard when the operator is clicked",
|
||||
default=False
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def description(cls, context, properties):
|
||||
tooltip = properties.tooltip
|
||||
if properties.copy_on_click:
|
||||
tooltip = "Copy to clipboard: " + properties.tooltip
|
||||
return tooltip
|
||||
|
||||
def execute(self, context):
|
||||
if self.copy_on_click:
|
||||
context.window_manager.clipboard = self.tooltip
|
||||
self.report({'INFO'}, "Copied to Clipboard: " + self.tooltip)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SVN_OT_clear_error(Operator):
|
||||
bl_idname = "svn.clear_error"
|
||||
bl_label = "Error:"
|
||||
bl_description = ""
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
process_id: StringProperty()
|
||||
|
||||
@classmethod
|
||||
def description(cls, context, properties):
|
||||
process = Processes.get(properties.process_id)
|
||||
if not process:
|
||||
return "Process doesn't exist: " + properties.process_id
|
||||
return process.error_description + "\n\n" + process.error + "\n\n Click to clear the error and copy it to your clipboard"
|
||||
|
||||
def execute(self, context):
|
||||
process = Processes.get(self.process_id)
|
||||
if not process:
|
||||
self.report({'WARNING'}, f'Process not found: "{self.process_id}"')
|
||||
return {'FINISHED'}
|
||||
context.window_manager.clipboard = process.error_description + "\n\n" + process.error
|
||||
|
||||
if process.repeat_delay > 0:
|
||||
process.start()
|
||||
else:
|
||||
process.error = ""
|
||||
process.output = ""
|
||||
|
||||
self.report({'INFO'}, "Copied error to Clipboard.")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
registry = [
|
||||
SVN_OT_custom_tooltip,
|
||||
SVN_OT_clear_error
|
||||
]
|
||||
Reference in New Issue
Block a user